Compare commits

...

261 Commits

Author SHA1 Message Date
Brad Fitzpatrick
b256c319c0 WIP
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-16 15:23:43 -07:00
Richard Castro
57da1f1501 client: update DNSConfig type (#8916)
This PR adds DNSFilterURL to the DNSConfig type to be used by
control changes to add DNS filtering logic

Fixes #cleanup

Signed-off-by: Richard Castro <richard@tailscale.com>
2023-08-16 14:54:17 -07:00
Chris Palmer
37c0b9be63 clientupdate: return NOTREACHED for macsys (#8898)
* clientupdate: return NOTREACHED for macsys

The work is done in Swift; this is now a documentation placeholder.

Updates #6995

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
2023-08-16 14:01:10 -07:00
Sonia Appasamy
18280ebf7d client/web: hook up data fetching to fill --dev React UI
Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-16 16:14:26 -04:00
David Anderson
623d72c83b net/art: move child table pointers out of strideEntry
In preparation for a different refactor, but incidentally also saves
10-25% memory on overall table size in benchmarks.

Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-08-16 12:17:25 -07:00
Charlotte Brandhorst-Satzkorn
f101a75dce cmd/tailscale/cli: fix comment accuracy
All exit nodes are shown under this subcommand.

Updates tailscale/corp#13025

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-08-16 11:10:50 -07:00
Adrian Dewhurst
f75a36f9bc tsweb: add request ID for errors
If an optional request ID generating func is supplied to StdHandler,
then requests that return an error will be logged with a request ID that
is also shown as part of the response.

Updates tailscale/corp#2549

Change-Id: Ic7499706df42f95b6878d44d4aab253e2fc6a69b
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2023-08-16 12:55:31 -04:00
Andrew Lytvynov
cf31b58ed1 VERSION.txt: this is v1.49.0 (#8907)
Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-16 09:37:36 -07:00
Brad Fitzpatrick
6c791f7d60 derp: include src IPs in mesh watch messages
Updates tailscale/corp#13945

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-16 08:46:52 -07:00
KevinLiang10
7ed3681cbe tailcfg: Add FirewallMode to NetInfo to record wether host using iptables or nftables
To record wether user is using iptables or nftables after we add support to nftables on linux, we
are adding a field FirewallMode to NetInfo in HostInfo to reflect what firewall mode the host is
running, and form metrics. The information is gained from a global constant in hostinfo.go. We
set it when selection heuristic made the decision, and magicsock reports this to control.

Updates: tailscale/corp#13943
Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-08-15 18:52:51 -04:00
Andrew Dunham
95d776bd8c wgengine/magicsock: only cache N most recent endpoints per-Addr
If a node is flapping or otherwise generating lots of STUN endpoints, we
can end up caching a ton of useless values and sending them to peers.
Instead, let's apply a fixed per-Addr limit of endpoints that we cache,
so that we're only sending peers up to the N most recent.

Updates tailscale/corp#13890

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I8079a05b44220c46da55016c0e5fc96dd2135ef8
2023-08-15 14:06:42 -07:00
Will Norris
9c4364e0b7 client/web: copy existing UI to basic react components
This copies the existing go template frontend into very crude react
components that will be driven by a simple JSON api for fetching and
updating data.  For now, this returns a static set of test data.

This just implements the simple existing UI, so I've put these all in a
"legacy" component, with the expectation that we will rebuild this with
more properly defined components, some pulled from corp.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-15 12:01:36 -07:00
Will Norris
ddba4824c4 client/web: add prettier and format scripts
Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-15 12:01:36 -07:00
Brad Fitzpatrick
bd02d00608 ssh/tailssh: fix gokrazy SSH crash
Stupid mistake in earlier refactor.

Updates gokrazy/gokrazy#209

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-15 10:56:25 -07:00
Brad Fitzpatrick
25a8daf405 wgengine/router: always use nftables mode on gokrazy
Updates gokrazy/gokrazy#209

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-15 10:55:56 -07:00
Brad Fitzpatrick
17ce75347c wgengine: adjust debug logging for WireGuard-only peers
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-15 10:05:29 -07:00
Sonia Appasamy
1a64166073 cli/serve: add interactive flow for enabling HTTPS certs
When trying to use serve with https, send users through https cert
provisioning enablement before editing the ServeConfig.

Updates tailscale/corp#10577

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-15 12:32:58 -04:00
Sonia Appasamy
0052830c64 cli/serve: funnel interactive enablement flow tweaks
1. Add metrics to funnel flow.
2. Stop blocking users from turning off funnels when no longer in
   their node capabilities.
3. Rename LocalClient.IncrementMetric to IncrementCounter to better
   callout its usage is only for counter clientmetrics.

Updates tailscale/corp#10577

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-15 00:46:18 -04:00
Sonia Appasamy
8e63d75018 client/tailscale: add LocalClient.IncrementMetric func
A #cleanup to add a func to utilize the already-present
"/localapi/v0/upload-client-metrics" localapi endpoint.

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-14 18:56:30 -04:00
Aaron Klotz
c17a817769 util/osdiag: add logging for winsock layered service providers to Windows bugreports
The Layered Service Provider (LSP) is a deprecated (but still supported)
mechanism for inserting user-mode DLLs into a filter chain between the
Winsock API surface (ie, ws2_32.dll) and the internal user-mode interface
to the networking stack.

While their use is becoming more rare due to the aforementioned deprecation,
it is still possible for third-party software to install their DLLs into
this filter chain and interfere with Winsock API calls. Knowing whether
this is happening is useful for troubleshooting.

Fixes https://github.com/tailscale/tailscale/issues/8142

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-08-14 15:03:54 -06:00
KevinLiang10
411e3364a9 wgengine/router: use iptablesRunner when no firewall tool is available:
The current router errors out when neither iptables nor nftables support is present. We
should fall back to the previous behaviour which we creates a dummy iptablesRunner.

Fixes: #8878
Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-08-14 16:21:23 -04:00
Sonia Appasamy
12238dab48 client/web: add tailwind styling to react app
Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-14 15:29:12 -04:00
Aaron Klotz
b07347640c util/winutil/authenticode: add missing docs for CertSubjectError
A #cleanup PR.

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-08-14 11:07:12 -06:00
Brad Fitzpatrick
1fcae42055 control/controlclient: move lastUpdateGenInformed to tighter scope
No need to have it on Auto or be behind a mutex; it's only read/written
from a single goroutine. Move it there.

Updates tailscale/corp#5761

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-13 10:39:17 -07:00
Brad Fitzpatrick
2398993804 control/controlclient: refactor in prep for optimized delta handling
See issue. This is a baby step towards passing through deltas
end-to-end from node to control back to node and down to the various
engine subsystems, not computing diffs from two full netmaps at
various levels. This will then let us support larger netmaps without
burning CPU.

But this change itself changes no behavior. It just changes a func
type to an interface with one method. That paves the way for future
changes to then add new NetmapUpdater methods that do more
fine-grained work than updating the whole world.

Updates #1909

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-12 16:09:58 -07:00
Denton Gentry
4940a718a1 scripts/installer.sh: set Deepin to a debian version
Deepin Linux 20.x reports its version as "apricot"
Set it to bullseye, the Debian version it corresponds to.

Also fix the installer CI: OpenSUSE Leap appears to have removed
curl from the base image recently, we have to install it now.

Fixes https://github.com/tailscale/tailscale/issues/8850
Updates https://github.com/tailscale/tailscale/issues/7862
Updates https://github.com/tailscale/corp/issues/8952

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-12 07:51:11 -07:00
M. J. Fromberger
9e24a6508a control/controlclient: avert a data race when logging (#8863)
The read of the synced field for logging takes place outside the lock, and
races with other (locked) writes of this field, including for example the one
at current line 556 in mapRoutine.

Updates tailscale/corp#13856

Change-Id: I056b36d7a93025aafdf73528dd7645f10b791af6
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2023-08-11 18:49:40 -07:00
Andrew Lytvynov
c40d095c35 clientupdate: implement updates for Synology (#8858)
Implement naive update for Synology packages, using latest versions from
pkgs.tailscale.com. This is naive because we completely trust
pkgs.tailscale.com to give us a safe package. We should switch this to
some better signing mechanism later.

I've only tested this on one DS218 box, so all the CPU architecture
munging is purely based on docs.

Updates #6995

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-11 14:55:07 -07:00
Brad Fitzpatrick
a1b8d703d6 tstime/mono: remove unsafe
This removes the unsafe/linkname and only uses the standard library.

It's a bit slower, for now, but https://go.dev/cl/518336 should get us
back.

On darwin/arm64, without https://go.dev/cl/518336

    pkg: tailscale.com/tstime/mono
              │   before    │                after                │
              │   sec/op    │   sec/op     vs base                │
    MonoNow-8   16.20n ± 0%   19.75n ± 0%  +21.92% (p=0.000 n=10)
    TimeNow-8   39.46n ± 0%   39.40n ± 0%   -0.16% (p=0.002 n=10)
    geomean     25.28n        27.89n       +10.33%

And with it,

    MonoNow-8   16.34n ±  1%   16.93n ± 0%  +3.67% (p=0.001 n=10)
    TimeNow-8   39.55n ± 15%   38.46n ± 1%  -2.76% (p=0.000 n=10)
    geomean     25.42n         25.52n       +0.41%

Updates #8839
Updates tailscale/go#70

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-11 13:23:16 -07:00
shayne
cc3caa4b2a hostinfo: add Home Assistant Add-On detection (#8857)
Fixes #8856

Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
2023-08-11 15:55:01 -04:00
James Tucker
de8e55fda6 net/netcheck,wgengine/magicsock: reduce coupling between netcheck and magicsock
Netcheck no longer performs I/O itself, instead it makes requests via
SendPacket and expects users to route reply traffic to
ReceiveSTUNPacket.

Netcheck gains a Standalone function that stands up sockets and
goroutines to implement I/O when used in a standalone fashion.

Magicsock now unconditionally routes STUN traffic to the netcheck.Client
that it hosts, and plumbs the send packet sink.

The CLI is updated to make use of the Standalone mode.

Fixes #8723

Signed-off-by: James Tucker <james@tailscale.com>
2023-08-11 10:08:21 -07:00
Sonia Appasamy
d5ac18d2c4 client/web: add tsconfig.json
Also allows us to use absolute import paths (see change in index.tsx).

Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-11 11:55:29 -04:00
Maisem Ali
21e32b23f7 .github: use testwrapper on windows
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-11 08:40:59 -07:00
Sonia Appasamy
3f12b9c8b2 client/web: pipe through to React in dev mode
Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-11 11:33:27 -04:00
Maisem Ali
98ec8924c2 ipn/ipnlocal,net/tsdial: update docs/rename funcs
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-11 07:00:08 -07:00
Brad Fitzpatrick
92fc9a01fa cmd/tailscale: add debug commands to break connections
For testing reconnects.

Updates tailscale/corp#5761

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-11 06:37:26 -07:00
salman aljammaz
99e06d3544 magicsock: set the don't fragment sockopt (#8715)
This sets the Don't Fragment flag, for now behind the
TS_DEBUG_ENABLE_PMTUD envknob.

Updates #311.

Signed-off-by: Val <valerie@tailscale.com>
Signed-off-by: salman <salman@tailscale.com>
2023-08-11 09:34:51 +01:00
Sonia Appasamy
16bc9350e3 client/web: add barebones vite dev setup
Currently just serving a "Hello world" page when running the web
cli in --dev mode.

Updates tailscale/corp#13775

Co-authored-by: Will Norris <will@tailscale.com>
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-10 21:54:43 -04:00
Andrew Lytvynov
215480a022 cmd/tailscale/cli,clientupdate: extract new clientupdate package (#8827)
Extract the self-update logic from cmd/tailscale/cli into a standalone
package that could be used from tailscaled later.

Updates #6995

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-10 17:01:22 -07:00
Sonia Appasamy
53c722924b tool/{node,yarn}: update node and yarn tools
Syncing these up with what we've got in corp.

Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-10 17:29:29 -04:00
Maisem Ali
d16946854f control/controlclient: add Auto.updateRoutine
Instead of having updates replace the map polls, create
a third goroutine which is solely responsible for making
sure that control is aware of the latest client state.

This also makes it so that the streaming map polls are only
broken when there are auth changes, or the client is paused.

Updates tailscale/corp#5761

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-10 12:23:08 -07:00
Brad Fitzpatrick
7a5263e6d0 util/linuxfw: rename ErrorFWModeNotSupported
Go style is for error variables to start with "err" (or "Err")
and for error types to end in "Error".

Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-10 09:27:05 -07:00
Will Norris
3d56cafd7d tsnet: add new example serving the Tailscale web client
Updates tailscale/corp#13775

Co-authored-by: Sonia Appasamy <sonia@tailscale.com>
Signed-off-by: Will Norris <will@tailscale.com>
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-10 08:34:33 -07:00
Will Norris
6ee85ba412 client/web: fix rendering of node owner profile
Fixes #8837

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-09 20:19:34 -07:00
Sonia Appasamy
2bc98abbd9 client/web: add web client Server struct
Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-09 19:09:09 -04:00
Sonia Appasamy
7815fbe17a tailscale/cli: add interactive flow for enabling Funnel
Updates tailscale/corp#10577

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-09 18:55:43 -04:00
Andrew Lytvynov
90081a25ca control/controlhttp: remove tstest.Clock from tests (#8830)
These specific tests rely on some timers in the controlhttp code.
Without time moving forward and timers triggering, the tests fail.

Updates #8587

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-09 15:03:22 -07:00
Aaron Klotz
3d2e35c053 util/winutil/authenticode: fix an inaccurate doc comment
A #cleanup PR

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-08-09 15:33:49 -06:00
Will Norris
f9066ac1f4 client/web: extract web client from cli package
move the tailscale web client out of the cmd/tailscale/cli package, into
a new client/web package.  The remaining cli/web.go file is still
responsible for parsing CLI flags and such, and then calls into
client/web. This will allow the web client to be hooked into from other
contexts (for example, from a tsnet server), and provide a dedicated
space to add more functionality to this client.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-09 09:53:37 -07:00
Will Norris
69f1324c9e cmd/tailscale: refactor shared utility methods
Refactor two shared functions used by the tailscale cli,
calcAdvertiseRoutes and licensesURL. These are used by the web client as
well as other tailscale subcommands. The web client is being moved out
of the cli package, so move these two functions to new locations.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-09 08:59:10 -07:00
Brad Fitzpatrick
b3618c23bf go.toolchain.rev: merge Go 1.21.0 final (non-rc)
Diff:
d149af2823..593313088f

Updates #8419

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-08 22:01:14 -07:00
Brad Fitzpatrick
be4eb6a39e derp, net/dns/recursive: use Go 1.21 min
Updates #8419

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-08 21:34:47 -07:00
Brad Fitzpatrick
66f27c4beb all: require Go 1.21
Updates #8419

Change-Id: I809b6a4d59d92a2ab6ec587ccbb9053376bf02c2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-08 21:15:08 -07:00
Maisem Ali
682fd72f7b util/testenv: add new package to hold InTest
Removes duplicated code.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-08 19:51:44 -06:00
Maisem Ali
3e255d76e1 ipn/ipnlocal: fix profile duplication
We would only look for duplicate profiles when a new login
occurred but when using `--force-reauth` we could switch
users which would end up with duplicate profiles.

Updates #7726

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-08 13:43:37 -06:00
Maisem Ali
500b9579d5 ipn/ipnlocal: add test to find issues with profile duplication
There are a few situations where we end up with duplicate profiles.
Add tests to identify those situations, fix in followup.

Updates #7726

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-08 13:43:37 -06:00
Maisem Ali
734928d3cb control/controlclient: make Direct own all changes to Persist
It was being modified in two places in Direct for the auth routine
and then in LocalBackend when a new NetMap was received. This was
confusing, so make Direct also own changes to Persist when a new
NetMap is received.

Updates #7726

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-08 13:43:37 -06:00
Maisem Ali
6aaf1d48df types/persist: drop duplicated Persist.LoginName
It was duplicated from Persist.UserProfile.LoginName, drop it.

Updates #7726

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-08 13:43:37 -06:00
KevinLiang10
ae63c51ff1 wgengine/router: add auto selection heuristic for iptables/nftables
This commit replaces the TS_DEBUG_USE_NETLINK_NFTABLES envknob with
a TS_DEBUG_FIREWALL_MODE that should be set to either 'iptables' or
'nftables' to select firewall mode manually, other wise tailscaled
will automatically choose between iptables and nftables depending on
environment and system availability.

updates: #319
Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-08-08 14:59:06 -04:00
Maisem Ali
17ed2da94d control/controlclient: use ptr.To
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-08 10:47:24 -06:00
Maisem Ali
82454b57dd ipn/ipnlocal: make tests pass when offline
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-08 10:28:55 -06:00
salman aljammaz
25a7204bb4 wgengine,ipn,cmd/tailscale: add size option to ping (#8739)
This adds the capability to pad disco ping message payloads to reach a
specified size. It also plumbs it through to the tailscale ping -size
flag.

Disco pings used for actual endpoint discovery do not use this yet.

Updates #311.

Signed-off-by: salman <salman@tailscale.com>
Co-authored-by: Val <valerie@tailscale.com>
2023-08-08 13:11:28 +01:00
Sonia Appasamy
49896cbdfa ipn/ipnlocal: add profile pic header to serve HTTP proxy
Fixes #8807

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-07 13:55:12 -04:00
Brad Fitzpatrick
c56e94af2d ipn: avoid useless no-op WriteState calls
Rather than make each ipn.StateStore implementation guard against
useless writes (a write of the same value that's already in the
store), do writes via a new wrapper that has a fast path for the
unchanged case.

This then fixes profileManager's flood of useless writes to AWS SSM,
etc.

Updates #8785

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-07 08:44:24 -07:00
License Updater
a3f11e7710 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-06 18:51:21 -07:00
Flakes Updater
10acc06389 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-08-05 10:02:40 -07:00
Claire Wang
a17c45fd6e control: use tstime instead of time (#8595)
Updates #8587
Signed-off-by: Claire Wang <claire@tailscale.com>
2023-08-04 19:29:44 -04:00
License Updater
a8e32f1a4b licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-04 09:43:49 -07:00
Andrew Lytvynov
371e1ebf07 cmd/dist,release/dist: expose RPM signing hook (#8789)
Plumb a signing callback function to `unixpkgs.rpmTarget` to allow
signing RPMs. This callback is optional and RPMs will build unsigned if
not set, just as before.

Updates https://github.com/tailscale/tailscale/issues/1882

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-03 15:27:06 -07:00
Andrew Lytvynov
eb6883bb5a go.mod: upgrade nfpm to v2 (#8786)
Upgrade the nfpm package to the latest version to pick up
24a43c5ad7.
The upgrade is from v0 to v2, so there was some breakage to fix.
Generated packages should have the same contents as before.

Updates https://github.com/tailscale/tailscale/issues/1882

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-03 13:00:45 -07:00
Aaron Klotz
37925b3e7a go.mod, cmd/tailscaled, ipn/localapi, util/osdiag, util/winutil, util/winutil/authenticode: add Windows module list to OS-specific logs that are written upon bugreport
* We update wingoes to pick up new version information functionality
  (See pe/version.go in the https://github.com/dblohm7/wingoes repo);
* We move the existing LogSupportInfo code (including necessary syscall
  stubs) out of util/winutil into a new package, util/osdiag, and implement
  the public LogSupportInfo function may be implemented for other platforms
  as needed;
* We add a new reason argument to LogSupportInfo and wire that into
  localapi's bugreport implementation;
* We add module information to the Windows implementation of LogSupportInfo
  when reason indicates a bugreport. We enumerate all loaded modules in our
  process, and for each one we gather debug, authenticode signature, and
  version information.

Fixes #7802

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-08-03 11:33:14 -06:00
Sonia Appasamy
301e59f398 tailcfg,ipn/localapi,client/tailscale: add QueryFeature endpoint
Updates tailscale/corp#10577

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-02 16:56:49 -04:00
Brad Fitzpatrick
ab7749aed7 go.toolchain.rev: go1.21rc4 (now that VERSION file is updated upstream)
Updates #8419

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-02 09:44:27 -07:00
Andrew Lytvynov
f57cc19ba2 cmd/tailscale/cli: add latest version output to "tailscale version" (#8700)
Add optional `--upstream` flag to `tailscale version` to fetch the
latest upstream release version from `pkgs.tailscale.com`. This is
useful to diagnose `tailscale update` behavior or write other tooling.

Example output:
$ tailscale version --upstream --json
{
	"majorMinorPatch": "1.47.35",
	"short": "1.47.35",
	"long": "1.47.35-t6afffece8",
	"unstableBranch": true,
	"gitCommit": "6afffece8a32509aa7a4dc2972415ec58d8316de",
	"cap": 66,
	"upstream": "1.45.61"
}

Fixes #8669

RELNOTE=adds "tailscale version --upstream"

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-02 08:24:18 -07:00
License Updater
b4c1f039b6 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-01 21:31:58 -07:00
Brad Fitzpatrick
c3b979a176 go.toolchain.rev: bump to ~go1.21rc4
Updates tailscale/go#69

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-01 21:19:24 -07:00
Sonia Appasamy
34bfd7b419 tailcfg: add CapabilityHTTPS const
A #cleanup

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-01 21:59:28 -04:00
Brad Fitzpatrick
66e46bf501 ipnlocal, net/*: deprecate interfaces.GetState, use netmon more for it
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-01 16:14:46 -07:00
License Updater
6d65c04987 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-01 14:06:13 -07:00
Tom DNetto
767e839db5 all: implement lock revoke-keys command
The revoke-keys command allows nodes with tailnet lock keys
to collaborate to erase the use of a compromised key, and remove trust
in it.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates ENG-1848
2023-08-01 15:37:55 -05:00
Aaron Klotz
7adf15f90e cmd/tailscale/cli, util/winutil/authenticode: flesh out authenticode support
Previously, tailscale upgrade was doing the bare minimum for checking
authenticode signatures via `WinVerifyTrustEx`. This is fine, but we can do
better:

* WinVerifyTrustEx verifies that the binary's signature is valid, but it doesn't
  determine *whose* signature is valid; tailscale upgrade should also ensure that
  the binary is actually signed *by us*.
* I added the ability to check the signatures of MSI files.
* In future PRs I will be adding diagnostic logging that lists details about
  every module (ie, DLL) loaded into our process. As part of that metadata, I
  want to be able to extract information about who signed the binaries.

This code is modelled on some C++ I wrote for Firefox back in the day. See
https://searchfox.org/mozilla-central/rev/27e4816536c891d85d63695025f2549fd7976392/toolkit/xre/dllservices/mozglue/Authenticode.cpp
for reference.

Fixes #8284

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-08-01 14:27:30 -06:00
Denton Gentry
ec9213a627 cmd/sniproxy: add client metrics
Count number of sessions, number of DNS queries answered
successfully and in error, and number of http->https redirects.

Updates #1748

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-01 12:14:01 -07:00
Andrew Lytvynov
eef15b4ffc cmd/dist,release/dist: sign release tarballs with an ECDSA key (#8759)
Pass an optional PEM-encoded ECDSA key to `cmd/dist` to sign all built
tarballs. The signature is stored next to the tarball with a `.sig`
extension.

Tested this with an `openssl`-generated key pair and verified the
resulting signature.

Updates #8760

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-31 15:47:00 -07:00
David Anderson
ed46442cb1 client/tailscale/apitype: document never-nil property of WhoIsResponse
Every time I use WhoIsResponse I end up writing mildly irritating nil-checking
for both Node and UserProfile, but it turns out our code guarantees that both
are non-nil in successful whois responses.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-31 10:15:44 -07:00
Brad Fitzpatrick
5ebb271322 derp/derphttp: add optional Client.BaseContext hook
Like net/http.Server.BaseContext, this lets callers specify a base
context for dials.

Updates tailscale/corp#12702

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-30 20:06:19 -07:00
Maisem Ali
058d427fa6 tailcfg: add helper to unmarshal PeerCap values
Updates #4217

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-07-29 19:31:44 -07:00
salman aljammaz
68f8e5678e wgengine/magicsock: remove dead code (#8745)
The nonce value is not read by anything, and di.sharedKey.Seal()
a few lines below generates its own. #cleanup

Signed-off-by: salman <salman@tailscale.com>
2023-07-29 18:53:33 +01:00
License Updater
0554deb48c licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-07-28 17:42:52 -04:00
David Anderson
6114247d0a types/logid: add a Compare method
Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-28 13:54:07 -07:00
David Anderson
52212f4323 all: update exp/slices and fix call sites
slices.SortFunc suffered a late-in-cycle API breakage.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-28 13:11:53 -07:00
Claire Wang
90a7d3066c derp: use tstime (#8634)
Updates #8587

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-07-27 15:56:33 -04:00
Claire Wang
2315bf246a ipn: use tstime (#8597)
Updates #8587
Signed-off-by: Claire Wang <claire@tailscale.com>
2023-07-27 15:41:31 -04:00
Andrew Lytvynov
c1ecae13ab ipn/{ipnlocal,localapi}: actually renew certs before expiry (#8731)
While our `shouldStartDomainRenewal` check is correct, `getCertPEM`
would always bail if the existing cert is not expired. Add the same
`shouldStartDomainRenewal` check to `getCertPEM` to make it proceed with
renewal when existing certs are still valid but should be renewed.

The extra check is expensive (ARI request towards LetsEncrypt), so cache
the last check result for 1hr to not degrade `tailscale serve`
performance.

Also, asynchronous renewal is great for `tailscale serve` but confusing
for `tailscale cert`. Add an explicit flag to `GetCertPEM` to force a
synchronous renewal for `tailscale cert`.

Fixes #8725

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-27 12:29:40 -07:00
tinku-tailscale
aa37be70cf api.md: update device authorize API docs to allow for deauth (#8728)
Signed-off-by: tinku-tailscale <139132124+tinku-tailscale@users.noreply.github.com>
2023-07-27 15:30:14 +01:00
Charlotte Brandhorst-Satzkorn
35bdbeda9f cli: introduce exit-node subcommand to list and filter exit nodes
This change introduces a new subcommand, `exit-node`, along with a
subsubcommand of `list` and a `--filter` flag.

Exit nodes without location data will continue to be displayed when
`status` is used. Exit nodes with location data will only be displayed
behind `exit-node list`, and in status if they are the active exit node.

The `filter` flag can be used to filter exit nodes with location data by
country.

Exit nodes with Location.Priority data will have only the highest
priority option for each country and city listed. For countries with
multiple cities, a <Country> <Any> option will be displayed, indicating
the highest priority node within that country.

Updates tailscale/corp#13025

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-07-26 16:41:52 -07:00
David Anderson
9d89e85db7 wgengine/magicsock: document mysterious-looking assignment
Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-26 14:57:01 -07:00
David Anderson
84777354a0 wgengine/magicsock: factor out more separable parts
Updates #8720

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-26 14:39:43 -07:00
David Anderson
9a76deb4b0 disco: move disco pcap helper to disco package
Updates tailscale/corp#13464

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-26 13:39:57 -07:00
David Anderson
cde37f5307 wgengine/magicsock: factor out peerMap into separate file
Updates tailscale/corp#13464

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-26 13:39:57 -07:00
David Anderson
f7016d8c00 wgengine/magicsock: factor out endpoint into its own file
Updates tailscale/corp#13464

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-26 12:05:32 -07:00
David Anderson
c2831f6614 wgengine/magicsock: delete unused stuff
Updates tailscale/corp#13464

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-26 11:44:41 -07:00
Andrew Lytvynov
9edb848505 cmd/tailscale/cli: implement update on FreeBSD (#8710)
Implement `tailscale update` on FreeBSD. This is much simpler than other
platforms because `pkg rquery` lets us get the version in their repos
without any extra parsing.

Updates #6995

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-25 17:38:25 -07:00
Maisem Ali
1ecc16da5f tailcfg,ipn/ipnlocal,wgengine: add values to PeerCapabilities
Define PeerCapabilty and PeerCapMap as the new way of sending down
inter-peer capability information.

Previously, this was unstructured and you could only send down strings
which got too limiting for certain usecases. Instead add the ability
to send down raw JSON messages that are opaque to Tailscale but provide
the applications to define them however they wish.

Also update accessors to use the new values.

Updates #4217

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-07-25 14:32:51 -07:00
Andrew Lytvynov
306deea03a cmd/tailscale/cli,version/distro: update support for Alpine (#8701)
Similar to Arch support, use the latest version info from the official
`apk` repo and don't offer explicit track or version switching.
Add detection for Alpine Linux in version/distro along the way.

Updates #6995

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-24 16:53:15 -07:00
David Anderson
6afffece8a net/art: use more intelligible, go-ish state tracking in table.Get
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
4f14ed2ad6 net/art: use encoding/binary for address bit twiddling
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
f1cd67488d net/art: move slice closer to its use
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
44ad7b3746 net/art: factor out picking the right strideTable for addr family
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
125b982ba5 net/art: make Table.Get alloc-free
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
b76d8a88ae net/art: document return value of strideTable.delete
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
b242e2c2cb net/art: reword confusing function docstring
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
8478358d77 net/art: use "index", not "idx" in function names
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
de5c6ed4be net/art: document valid values of strideTable.prefix
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
736a44264f net/art: fix comment typo
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
1e6f0bb608 net/art: fix slowPrefixTable bugs found by fuzzing
One is a straight "I forgot how to Go" bug, the others are semantic
mismatches with the main implementation around masking the prefixes
passed to insert/delete.

Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
aaca911904 net/art: add another consistency test for insert/delete
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
b145a22f55 net/art: add more exhaustive table testing
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
9cc3f7a3d6 net/art: fix format of debug output
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
ac657caaf1 net/art: add debug hooks to strideTable
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
fcf4d044fa net/art: implement path compression optimization
Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
486195edf0 net/art: make each strideTable track the IP prefix it represents
This is a prerequisite for path compression, so that insert/delete
can determine when compression occurred.

Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
David Anderson
45b5d0983c net/art: fix running tests outside of CI
Updates #7866

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-07-24 13:33:48 -07:00
License Updater
4c05d43008 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-07-24 10:42:00 -07:00
Andrew Lytvynov
894b237a70 cmd/tailscale/cli: implement update for dnf/yum-based distros (#8678)
This is the Fedora family of distros, including CentOS, RHEL and others.
Tested in `fedora:latest` and `centos:7` containers.

Updates #6995

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-24 10:20:17 -07:00
Brad Fitzpatrick
f1cc8ab3f9 tailcfg: add UserProfile.Groups
Updates tailscale/corp#13375

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-23 15:23:59 -07:00
Michael Stapelberg
2a6c237d4c net/dns: overwrite /tmp/resolv.conf on gokrazy
Appliances built using https://gokrazy.org/ have a read-only root file system,
including /etc/resolv.conf, which is a symlink to /tmp/resolv.conf.

The system’s dhcp client overwrites /tmp/resolv.conf instead,
so we need to use this path in Tailscale, too.

related to https://github.com/gokrazy/gokrazy/issues/209

fixes https://github.com/tailscale/tailscale/issues/8689

Signed-off-by: Michael Stapelberg <michael@stapelberg.de>
2023-07-23 09:53:32 -07:00
Brad Fitzpatrick
453620dca1 go.toolchain.rev: bump Go version
For a96a9eddc0

Updates tailscale/corp#12702

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-22 22:14:59 -07:00
Brad Fitzpatrick
41db1d7bba tailcfg: deprecate Debug, flesh out Node.DERP docs
Updates #docs

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-22 09:33:45 -07:00
Jenny Zhang
907c56c200 api.md: add documentation to API endpoint about SCIM group warnings
Updates #8645
Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-07-21 15:35:08 -04:00
Claire Wang
e1bcecc393 logtail: use tstime (#8607)
Updates #8587
Signed-off-by: Claire Wang <claire@tailscale.com>
2023-07-21 13:10:39 -04:00
Joe Tsai
bb4b35e923 ssh: ignore io.EOF from sftp.Server.Serve
If the connection provided to sftp.NewServer is closed,
Serve returns the io.EOF error verbatim from io.Reader.Read.
This is an odd error since this is an expected situation,
so we manually ignore io.EOF.
This is somewhat buggy since the sftp package itself
incorrectly reports io.EOF in cases where it should actually
be reporting io.ErrUnexpectedEOF.
See https://github.com/pkg/sftp/pull/554 which patches Serve to
return nil on clean closes and fixes buggy uses of io.ReadFull.

Fixes #8592

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-07-21 07:41:36 -07:00
Brad Fitzpatrick
88cc0ad9f7 util/linuxfw: remove yet-unused code to fix linux/arm64 crash
The util/linuxfw/iptables.go had a bunch of code that wasn't yet used
(in prep for future work) but because of its imports, ended up
initializing code deep within gvisor that panicked on init on arm64
systems not using 4KB pages.

This deletes the unused code to delete the imports and remove the
panic. We can then cherry-pick this back to the branch and restore it
later in a different way.

A new test makes sure we don't regress in the future by depending on
the panicking package in question.

Fixes #8658

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-20 23:18:40 -07:00
Brad Fitzpatrick
7560435eb5 tstest/deptest: add test-only package to unify negative dep tests
Updates #8658

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-20 23:18:40 -07:00
Chris Palmer
32d486e2bf cmd/tailscale/cli: ensure custom UsageFunc is always set (#8665)
Updates #6995

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
2023-07-20 17:43:39 -07:00
Chris Palmer
3c53bedbbf cmd/tailscale/cli: limit Darwin-only option to Darwin (#8657) 2023-07-20 11:19:09 -07:00
Anton Tolchanov
388b124513 net/dns: detect when libnss_resolve is used
Having `127.0.0.53` is not the only way to use `systemd-resolved`. An
alternative way is to enable `libnss_resolve` module, which seems to now
be used by default on Debian 12 bookworm.

Fixes #8549

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-07-20 18:26:23 +02:00
Andrew Lytvynov
efd6d90dd7 cmd/tailscale/cli: implement update for arch-based distros (#8655)
Arch version of tailscale is not maintained by us, but is generally
up-to-date with our releases. Therefore "tailscale update" is just a
thin wrapper around "pacman -Sy tailscale" with different flags.

Updates #6995

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-19 17:53:56 -07:00
Chris Palmer
3f6b0d8c84 cmd/tailscale/cli: make tailscale update query softwareupdate (#8641)
* cmd/tailscale/cli: make `tailscale update` query `softwareupdate`

Even on macOS when Tailscale was installed via the App Store, we can check for
and even install new versions if people ask explicitly. Also, warn if App Store
AutoUpdate is not turned on.

Updates #6995
2023-07-19 17:06:16 -07:00
Tom DNetto
bec9815f02 tka: guard against key-length panics when verifying signatures
In late 2022 a subtle but crucial part of documentation was added to ed25519.Verify: It
will panic if len(publicKey) is not [PublicKeySize].

02ed0e5e67

This change catches that error so it won't lead to a panic.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates https://github.com/tailscale/corp/issues/8568
2023-07-19 15:33:01 -05:00
Andrea Gottardo
486ab427b4 VERSION.txt: this is v1.47.0 (#8654)
Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
2023-07-19 13:02:01 -07:00
Andrew Lytvynov
7c04846eac tsweb: relax CSP for debug handlers (#8649)
Allow inline CSS for debug handlers to make prototyping easier. These
are generally not accessible to the public and the small risk of CSS
injection via user content seems acceptable.

Also allow form submissions on the same domain, instead of banning all
forms. An example of such form is
http://webhooks.corp.ts.net:6359/debug/private-nodes/

Updates #3576

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-19 11:58:29 -07:00
Jenny Zhang
9ab70212f4 cmd/gitops-pusher: re-use existing types from acl package
This changes the ACLTestError type to reuse the existing/identical
types from the ACL implementation, to avoid issues in the future if
the two types fall out of sync.

Updates #8645

Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-07-19 14:44:45 -04:00
Jenny Zhang
6b56e92acc client/tailscale: add warnings slice to ACLTestFailureSummary
Updates #8645

Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-07-19 14:44:45 -04:00
KevinLiang10
a3c7b21cd1 util/linuxfw: add nftables support
This commit adds nftable rule injection for tailscaled. If tailscaled is
started with envknob TS_DEBUG_USE_NETLINK_NFTABLES = true, the router
will use nftables to manage firewall rules.

Updates: #391

Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-07-19 14:33:23 -04:00
Tom DNetto
abcb7ec1ce cmd/tailscale: warn if node is locked out on bringup
Updates https://github.com/tailscale/corp/issues/12718
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-07-19 12:31:21 -05:00
Tom DNetto
2c782d742c tka: allow checkpoint AUMs to change TKA state
Updates https://github.com/tailscale/corp/issues/8568
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-07-19 12:06:20 -05:00
Xe Iaso
24f0e91169 tsnet: add examples (#8289)
Closes #8288

Follows the examples from the KB[1].

[1]: https://tailscale.com/kb/1244/tsnet/

Signed-off-by: Xe Iaso <xe@tailscale.com>
2023-07-19 12:36:49 -04:00
Val
1138f4eb5f wgengine/router,ipn/ipnlocal: add MTU field to router config
The MacOS client can't set the MTU when creating the tun due to lack
of permissions, so add it to the router config and have MacOS set it
in the callback using a method that it does have permissions for.

Updates #8219

Signed-off-by: Val <valerie@tailscale.com>
2023-07-19 04:22:12 -07:00
Andrew Dunham
9b5e29761c net/netcheck: ignore PreferredDERP changes that are small
If the absolute value of the difference between the current
PreferredDERP's latency and the best latency is <= 10ms, don't change
it and instead prefer the previous value.

This is in addition to the existing hysteresis that tries to remain
on the previous DERP region if the relative improvement is small, but
handles nodes that have low latency to >1 DERP region better.

Updates #8603

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I1e34c94178f8c9a68a69921c5bc0227337514c70
2023-07-18 16:50:24 -04:00
dependabot[bot]
8bdc03913c go.mod: bump github.com/docker/distribution (#8121)
Bumps [github.com/docker/distribution](https://github.com/docker/distribution) from 2.8.1+incompatible to 2.8.2+incompatible.
- [Release notes](https://github.com/docker/distribution/releases)
- [Commits](https://github.com/docker/distribution/compare/v2.8.1...v2.8.2)

---
updated-dependencies:
- dependency-name: github.com/docker/distribution
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-18 11:05:26 -07:00
Maisem Ali
3304819739 metrics: add histogram support
Add initial histogram support.

Updates tailscale/corp#8641

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-07-18 09:18:55 -07:00
License Updater
9101fabdf8 licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-07-18 05:14:01 -07:00
Brad Fitzpatrick
94a51bdd62 go.toolchain.rev: bump Go to Go1.21rc3
Updates #8632

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-18 04:00:50 -07:00
Will Norris
f8b0caa8c2 serve: fix hostname for custom http ports
When using a custom http port like 8080, this was resulting in a
constructed hostname of `host.tailnet.ts.net:8080.tailnet.ts.net` when
looking up the serve handler. Instead, strip off the port before adding
the MagicDNS suffix.

Also use the actual hostname in `serve status` rather than the literal
string "host".

Fixes #8635

Signed-off-by: Will Norris <will@tailscale.com>
2023-07-17 17:10:14 -07:00
Andrew Lytvynov
c19b5bfbc3 .github/workflows: add govulncheck workflow (#8623) 2023-07-14 14:56:21 -07:00
Claire Wang
0573f6e953 tstime: add Since method (#8622)
Updates #8463

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-07-14 16:50:17 -04:00
Maisem Ali
60e5761d60 control/controlclient: reset backoff in mapRoutine on netmap recv
We were never resetting the backoff in streaming mapResponses.
The call to `PollNetMap` always returns with an error. Changing that contract
is harder, so manually reset backoff when a netmap is received.

Updates tailscale/corp#12894

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-07-13 21:08:28 -07:00
Andrew Dunham
7aba0b0d78 net/netcheck, tailcfg: add DERPHomeParams and use it
This allows providing additional information to the client about how to
select a home DERP region, such as preferring a given DERP region over
all others.

Updates #8603

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I7c4a270f31d8585112fab5408799ffba5b75266f
2023-07-13 22:41:21 -04:00
Andrew Lytvynov
7a82fd8dbe ipn/ipnlocal: add optional support for ACME Renewal Info (ARI) (#8599) 2023-07-13 14:29:59 -07:00
Andrew Lytvynov
354885a08d wgengine/netlog: fix nil pointer dereference in logtail (#8598) 2023-07-13 08:54:29 -07:00
Denton Gentry
4f95b6966b cmd/tailscale: remove TS_EXPERIMENT_OAUTH_AUTHKEY guardrail
We've had support for OAuth client keys in `--authkey=...`
for several releases, and we're using it in
https://github.com/tailscale/github-action

Remove the TS_EXPERIMENT_* guardrail, it is fully supported now.

Fixes https://github.com/tailscale/tailscale/issues/8403

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-07-13 07:20:38 -07:00
License Updater
c95de4c7a8 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-07-12 12:26:40 -07:00
License Updater
3d70fecde4 licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-07-12 12:25:19 -07:00
Andrew Lytvynov
96d7af3469 cmd/derper,tsweb: consistently add HTTP security headers (#8579)
Add a few helper functions in tsweb to add common security headers to handlers. Use those functions for all non-tailscaled-facing endpoints in derper.

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-07-11 11:53:46 -07:00
Maisem Ali
8cda647a0f cmd/testwrapper: handle build failures
`go test -json` outputs invalid JSON when a build fails.
Handle that case by reseting the json.Decode and continuing to read.

Updates #8493

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-07-11 11:44:30 -07:00
Joe Tsai
49015b00fe logtail: fix race condition with sockstats label (#8578)
Updates tailscale/corp#8427

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-07-11 10:51:51 -07:00
Tom DNetto
2bbedd2001 ipn: rename CapTailnetLockAlpha -> CapTailnetLock
Updates tailscale/corp#8568
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-07-11 12:47:02 -05:00
Andrew Dunham
60ab8089ff logpolicy, various: allow overriding log function
This allows sending logs from the "logpolicy" package (and associated
callees) to something other than the log package. The behaviour for
tailscaled remains the same, passing in log.Printf

Updates #8249

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ie1d43b75fa7281933d9225bffd388462c08a5f31
2023-07-10 18:08:50 -04:00
Flakes Updater
cd313e410b go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-07-10 13:25:20 -07:00
Adrian Dewhurst
8c0572e088 go.mod: bump wireguard-go
This pulls in IP checksum optimization on amd64, see
tailscale/wireguard-go@bb2c8f2.

Updates tailscale/corp#9755

Change-Id: I60e932fc4031703b56eb86a676465c5d02d99236
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2023-07-10 16:20:47 -04:00
Andrew Dunham
a7648a6723 net/dnsfallback: run recursive resolver and compare results
When performing a fallback DNS query, run the recursive resolver in a
separate goroutine and compare the results returned by the recursive
resolver with the results we get from "regular" bootstrap DNS. This will
allow us to gather data about whether the recursive DNS resolver works
better, worse, or about the same as "regular" bootstrap DNS.

Updates #5853

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ifa0b0cc9eeb0dccd6f7a3d91675fe44b3b34bd48
2023-07-10 16:12:48 -04:00
Brad Fitzpatrick
ffaa6be8a4 tailcfg: add json omitempty to DNSConfig.ExitNodeFilteredSet
We were storing a lot of "ExitNodeFilteredSet":null in the database.

Updates tailscale/corp#1818 (found in the process)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-08 19:58:23 -07:00
Brad Fitzpatrick
7b1c3dfd28 tailcfg,etc: remove unused tailcfg.Node.KeepAlive field
The server hasn't sent it in ages.

Updates #cleanup

Change-Id: I9695ab0f074ec6fb006e11faf3cdfc5ca049fbf8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-08 06:57:10 -07:00
Charlotte Brandhorst-Satzkorn
f05a9f3e7f tsnet: mark TestLoopbackLocalAPI as flakey
Test flaked in CI.

Updates  #8557

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-07-07 21:23:48 -07:00
Charlotte Brandhorst-Satzkorn
339397ab74 wgengine/magicsock: remove noV4/noV6 check in addrForSendWireGuardLocked
This change removes the noV4/noV6 check from addrForSendWireGuardLocked.

On Android, the client panics when reaching	`rand.Intn()`, likely due to
the candidates list being containing no candidates. The suspicion is
that the `noV4` and the `noV6` are both being triggered causing the
loop to continue.

Updates tailscale/corp#12938
Updates #7826

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-07-07 18:59:19 -07:00
Maisem Ali
9d1a3a995c control/controlclient: use ctx passed down to NoiseClient.getConn
Without this, the client would just get stuck dialing even if the
context was canceled.

Updates tailscale/corp#12590

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-07-07 15:11:44 -07:00
Adrian Dewhurst
92fb80d55f tstest, tstime: mockable timers and tickers
This change introduces tstime.Clock which is the start of a mockable
interface for use with testing other upcoming code changes.

Fixes #8463

Change-Id: I59eabc797828809194575736615535d918242ec4
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2023-07-07 11:13:03 -04:00
Andrea Gottardo
28ee355c56 version: fix tvOS network extension bundle identifier (#8545)
Fixes #8544 and updates #8282.

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
2023-07-06 16:19:15 -07:00
Adrian Dewhurst
cd4c71c122 tstest: prepare for Clock API changes
This change introduces tstime.NewClock and tstime.ClockOpts as a new way
to construct tstime.Clock. This is a subset of #8464 as a stepping stone
so that we can update our internal code to use the new API before making
the second round of changes.

Updates #8463

Change-Id: Ib26edb60e5355802aeca83ed60e4fdf806c90e27
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2023-07-06 17:03:19 -04:00
Aaron Klotz
fd8c8a3700 client/tailscale: add API for verifying network lock signing deeplink
Fixes #8539

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-07-06 09:59:24 -07:00
Claire Wang
3f1f906b63 words: ack Miles Prower (#8479)
Signed-off-by: Claire Wang <claire@tailscale.com>
2023-07-06 11:29:09 -04:00
Brad Fitzpatrick
cb53846717 tempfork/heap: add copy of Go's container/heap but using generics
From Go commit 0a48e5cbfabd679e, then with some generics sprinkled
about.

Updates tailscale/corp#7354

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-05 19:33:56 -07:00
David Wolever
0c427f23bd docs/k8s: don't call kubectl directly from Makefile
Instead of calling kubectl directly in k8s Makefile, write the yaml to
stdout so it can be reviewed/edited/etc before manually applying with
kubectl.

Fixes: #8511

Signed-off-by: David Wolever <david@wolever.net>
2023-07-01 11:44:09 -07:00
Brad Fitzpatrick
4d94d72fba metrics: add LabelMap.GetIncrFunc
Updates tailscale/corp#7354

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-07-01 07:14:55 -07:00
David Anderson
0a86705d59 release/dist: add helper to build Go binaries with custom tags
Updates tailscale/corp#12861

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-06-30 19:03:03 -07:00
KevinLiang10
a795b4a641 tsnet: add TestLoopbackSOCKS5 as flaky test
Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-06-30 18:22:15 -04:00
KevinLiang10
6ebd87c669 util/linuxfw: add new arch build constraints
Exclide GOARCHs including: mips, mips64, mips64le, mipsle, riscv64.
These archs are not supported by gvisor.dev/gvisor/pkg/hostarch.

Fixes: #391
Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-06-30 18:22:15 -04:00
Maisem Ali
1ca5dcce15 cmd/testwrapper: stream output results
Previously it would wait for all tests to run before printing anything,
instead stream the results over a channel so that they can be emitted
immediately.

Updates #8493

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-30 12:37:32 -07:00
Maisem Ali
2e4e7d6b9d cmd/testwrapper: output packages tested
Previously it would only print the failures without providing
more information on which package the failures from.

This commit makes it so that it prints out the package information
as well as the attempt numbers.

```
➜  tailscale.com git:(main) ✗ go run ./cmd/testwrapper ./cmd/...
ok       tailscale.com/cmd/derper
ok       tailscale.com/cmd/k8s-operator
ok       tailscale.com/cmd/tailscale/cli
ok       tailscale.com/cmd/tailscaled
=== RUN   TestFlakeRun
    flakytest.go:38: flakytest: issue tracking this flaky test: https://github.com/tailscale/tailscale/issues/0
    flakytest_test.go:41: First run in testwrapper, failing so that test is retried. This is expected.
--- FAIL: TestFlakeRun (0.00s)
FAIL     tailscale.com/cmd/testwrapper/flakytest

Attempt #2: Retrying flaky tests:

ok       tailscale.com/cmd/testwrapper/flakytest
```

Updates #8493

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-30 11:17:09 -07:00
Brad Fitzpatrick
79ee6d6e1e tsweb/varz: use default metrics.LabelMap.Label on serialization
To not break Prometheus if the label is unset.

Updates tailscale/corp#12830

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-30 09:08:43 -07:00
Maisem Ali
2e19790f61 types/views: add JSON marshal/unmarshal and AsMap to Map
This allows cloning a Map as well as marshaling the Map
as JSON.

Updates tailscale/corp#12754

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-29 10:12:26 -07:00
Joe Tsai
e42be5a060 tstime/mono: fix Time.Unmarshal (#8480)
Calling both mono.Now() and time.Now() is slow and
leads to unnecessary precision errors.
Instead, directly compute mono.Time relative to baseMono and baseWall.
This is the opposite calculation as mono.Time.WallTime.

Updates tailscale/corp#8427

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-06-28 15:16:52 -07:00
Maisem Ali
075abd8ec1 tstest: do not panic when a panic logs
The panicLogWriter is too strict, and any panics that occur
get wrapped up in quotes. This makes it so that it will allow
panics to continue writing to Stderr without going through
logger.Logf.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-28 13:27:26 -07:00
Maisem Ali
12a2221db2 cmd/testwrapper/flakytest: clearly describe why TestFlakeRun fails
Fixes #8474

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-28 12:19:25 -07:00
Tom DNetto
97ee0bc685 cmd/tailscale: improve error message when signing without a tailnet lock key
Updates #8568
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-06-28 14:19:05 -05:00
Brad Fitzpatrick
b0a984dc26 util/lru: add a package for a typed LRU cache
Updates tailscale/corp#7355

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-28 10:16:15 -07:00
License Updater
626f650033 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-06-28 10:05:31 -07:00
License Updater
d4413f723d licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-06-28 10:05:10 -07:00
Brad Fitzpatrick
cafd9a2bec syncs: add ShardedMap.Mutate
To let callers do atomic/CAS-like operations.

Updates tailscale/corp#7355

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-28 08:43:15 -07:00
Andrew Dunham
ab310a7f60 derp: use new net/tcpinfo package
Updates #8413

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I8bf8046517195a6d42cabb32d6ec7f1f79cef860
2023-06-27 21:59:43 -04:00
Andrew Dunham
d9eca20ee2 net/tcpinfo: add package to allow fetching TCP information
This package contains platform-independent abstractions for fetching
information about an open TCP connection.

Updates #8413

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I236657b1060d7e6a45efc7a2f6aacf474547a2fe
2023-06-27 21:59:43 -04:00
KevinLiang10
243ce6ccc1 util/linuxfw: decoupling IPTables logic from linux router
This change is introducing new netfilterRunner interface and moving iptables manipulation to a lower leveled iptables runner.

For #391

Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-06-27 19:54:27 -04:00
Charlotte Brandhorst-Satzkorn
9c64e015e5 tailcfg: update location docs
This change updates the documentation for the fields on the location
struct.

Updates tailscale/corp#12146

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-06-27 12:29:29 -07:00
Ross Zurowski
832f1028c7 net/netutil: parse IP forwarding val as int, not bool (#8455)
This commit updates our IP forwarding parsing logic to allow the less
common but still valid value of `2` to be parsed as `true`, which fixes
an error some users encountered.

Fixes #8375

Signed-off-by: Ross Zurowski <ross@rosszurowski.com>
2023-06-27 15:00:42 -04:00
Brad Fitzpatrick
a874f1afd8 all: adjust case of "IPv4" and "IPv6"
Updates #docs

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-27 10:09:30 -07:00
valscale
e26376194d ipn/ipnlocal: validate ping type (#8458)
Correct a minor cut-n-paste error that resulted in an invalid or
missing ping type being accepted as a disco ping.

Fixes #8457

Signed-off-by: Val <valerie@tailscale.com>
2023-06-27 09:33:29 -07:00
Brad Fitzpatrick
77f56794c9 types/key: add test for NodePublic.Shard
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-27 08:49:44 -07:00
Tom DNetto
1377618dbc tsnet: expose field to configure Wireguard port
Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates #1748
2023-06-26 18:11:36 -05:00
Maisem Ali
8e840489ed cmd/testwrapper: only retry flaky failed tests
Redo the testwrapper to track and only retry flaky tests instead
of retrying the entire pkg. It also fails early if a non-flaky test fails.

This also makes it so that the go test caches are used.

Fixes #7975

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-26 08:54:20 -07:00
Anton Tolchanov
2cf6e12790 hostinfo: make lxcfs container check more specific
Instead of treating any lxcfs mount as an indicator that we're running
in a container, check for one of the mounts actually used by LXC
containers.

For reference, here's a list of mounts I am seeing in an LXC container:

```
$ grep lxcfs /proc/mounts
lxcfs /proc/cpuinfo fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
lxcfs /proc/diskstats fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
lxcfs /proc/loadavg fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
lxcfs /proc/meminfo fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
lxcfs /proc/stat fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
lxcfs /proc/swaps fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
lxcfs /proc/uptime fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
lxcfs /sys/devices/system/cpu/online fuse.lxcfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other 0 0
```

Fixes #8444

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-06-26 15:34:54 +01:00
Maisem Ali
c11af12a49 .github: actually run tests in CI
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-25 15:57:28 -07:00
Brad Fitzpatrick
ba41d14320 syncs: add ShardedMap type
Updates tailscale/corp#7354

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-25 12:51:19 -07:00
Brad Fitzpatrick
1f57088cbd words: ovuliferous was a bit too much, but...
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-24 13:30:49 -07:00
Charlotte Brandhorst-Satzkorn
3417ddc00c tailcfg: add location field to hostinfo
This change adds Location field to HostInfo.
Location contains the option for a Country, CountryCode, City, CityCode
and a Priority. Neither of these fields are populated by default.

The Priority field is used to determine the priority an exit
node should be given for use, if the field is set. The higher the value
set, the higher priority the node should be given for use.

Updates tailscale/corp#12146

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-06-23 15:34:31 -07:00
phirework
2a9817da39 api.md: add info for key description (#8429)
Updates tailscale/corp#7773

Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-06-23 17:38:20 -04:00
David Anderson
bfe5623a86 tool/gocross: make gocross behave with pre-release Go toolchains
Also switch the wrapper script to use bash not posix shell. We now
depend on bash elsewhere for saner behavior in esoteric areas, so
might as well use it everywhere for consistency.

Fixes #8425

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-06-23 13:57:05 -07:00
David Anderson
4a58b1c293 release/dist/synology: remove 'version' field from ui/config
As far as I can tell from the DSM documentation and known undocumented
fields, there is no 'version' field in this config file that DSM cares
about.

Updates #8232

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-06-23 13:43:15 -07:00
Brad Fitzpatrick
7c1068b7ac util/goroutines: let ScrubbedGoroutineDump get only current stack
ScrubbedGoroutineDump previously only returned the stacks of all
goroutines. I also want to be able to use this for only the current
goroutine's stack. Add a bool param to support both ways.

Updates tailscale/corp#5149

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-23 12:50:45 -07:00
phirework
fbacc0bd39 go.toolchain: switch to tailscale.go1.21 (#8415)
Updates #8419

Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-06-23 09:12:19 -04:00
Brad Fitzpatrick
8b80d63b42 wgengine/magicsock: clarify a log message is a warning, not an error
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-22 08:16:41 -07:00
Joe Tsai
61886e031e ssh/tailssh: fix double race condition with non-pty command (#8405)
There are two race conditions in output handling.

The first race condition is due to a misuse of exec.Cmd.StdoutPipe.
The documentation explicitly forbids concurrent use of StdoutPipe
with exec.Cmd.Wait (see golang/go#60908) because Wait will
close both sides of the pipe once the process ends without
any guarantees that all data has been read from the pipe.
To fix this, we allocate the os.Pipes ourselves and
manage cleanup ourselves when the process has ended.

The second race condition is because sshSession.run waits
upon exec.Cmd to finish and then immediately proceeds to call ss.Exit,
which will close all output streams going to the SSH client.
This may interrupt any asynchronous io.Copy still copying data.
To fix this, we close the write-side of the os.Pipes after
the process has finished (and before calling ss.Exit) and
synchronously wait for the io.Copy routines to finish.

Fixes #7601

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
Co-authored-by: Maisem Ali <maisem@tailscale.com>
2023-06-21 19:57:45 -07:00
dependabot[bot]
d4de60c3ae .github: Bump actions/checkout from 1 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 1 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-06-21 12:01:59 -07:00
Charlotte Brandhorst-Satzkorn
30d9201a11 VERSION.txt: this is v1.45.0
Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-06-21 10:54:17 -07:00
Brad Fitzpatrick
32b8f25ed1 Revert "ssh/tailssh: change to user directory when running login/command"
This reverts commit dc5bc32d8f.

It broke tests. (sadly, ones which we have disabled on CI, but go test
./ssh/tailssh broke)
2023-06-21 10:49:18 -07:00
Aaron Bieber
6829caf6de tsnet: remove extra wording from Store comment 2023-06-21 10:28:11 -07:00
Brad Fitzpatrick
e48c0bf0e7 ipn/ipnlocal: quiet some spammy network lock logging
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-21 10:24:53 -07:00
valscale
f314fa4a4a prober: fix data race when altering derpmap (#8397)
Move the clearing of STUNOnly flag to the updateMap() function.

Fixes #8395

Signed-off-by: Val <valerie@tailscale.com>
2023-06-21 10:16:31 -07:00
Derek Burdick
dc5bc32d8f ssh/tailssh: change to user directory when running login/command
On redhat 9 and similarly locked down systems, root user does not have
access to a users directory. This fix does not set a directory for the
incubator process and instead sets the directory when the actual process
requested by remote user is executed.

Fixes #8118

Signed-off-by: Derek Burdick <derek-burdick@users.noreply.github.com>
2023-06-21 10:14:19 -07:00
shayne
6697690b55 {cmd/tailscale/cli,ipn}: add http support to tailscale serve (#8358)
Updates #8357

Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
2023-06-21 12:32:20 -04:00
Brad Fitzpatrick
a2153afeeb types/views: add Slice methods on Slice views
Updates #cleanup for change elsewhere.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-20 20:19:39 -07:00
Sonia Appasamy
0f5090c526 ipn/ipnlocal: add docs header to serve HTTP proxy
Adds a `Tailscale-Headers-Info` header whenever the `Tailscale-User-`
headers are filled from the HTTP proxy handler.

Planning on hooking this shorturl up to KB docs about the header
values (i.e. what's a login name vs. display name) and security
considerations to keep in mind while using these headers - notibly
that they can also be filled from external requests that do not hit
tailscaled.

Updates https://github.com/tailscale/tailscale/issues/6954

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-06-20 16:46:10 -04:00
valscale
88097b836a prober: allow monitoring of nodes marked as STUN only in default derpmap (#8391)
prober uses NewRegionClient() to connect to a derper using a faked up
single-node region, but NewRegionClient() fails to connect if there is
no non-STUN only client in the region. Set the STUN only flag to false
before we call NewRegionClient() so we can monitor nodes marked as
STUN only in the default derpmap.

Updates #11492

Signed-off-by: Val <valerie@tailscale.com>
2023-06-20 12:04:55 -07:00
Maisem Ali
2ae670eb71 ssh/tailssh: work around lack of scontext in SELinux
Trying to SSH when SELinux is enforced results in errors like:

```
➜  ~ ssh ec2-user@<ip>
Last login: Thu Jun  1 22:51:44 from <ip2>
ec2-user: no shell: Permission denied
Connection to <ip> closed.
```

while the `/var/log/audit/audit.log` has
```
type=AVC msg=audit(1685661291.067:465): avc:  denied  { transition } for  pid=5296 comm="login" path="/usr/bin/bash" dev="nvme0n1p1" ino=2564 scontext=system_u:system_r:unconfined_service_t:s0 tcontext=unconfined_u:unconfined_r:unconfined_t:s0 tclass=process permissive=0
```

The right fix here would be to somehow install the appropriate context when
tailscale is installed on host, but until we figure out a way to do that
stop using the `login` cmd in these situations.

Updates #4908

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-20 10:44:22 -07:00
Ross Zurowski
0ed088b47b tka: add function for generating signing deeplinks (#8385)
This commit continues the work from #8303, providing a method for a
tka.Authority to generate valid deeplinks for signing devices. We'll
use this to provide the necessary deeplinks for users to sign from
their mobile devices.

Updates #8302

Signed-off-by: Ross Zurowski <ross@rosszurowski.com>
2023-06-20 09:36:37 -07:00
Flakes Updater
909e9eabe4 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-06-19 10:42:31 -04:00
Andrew Dunham
b6d20e6f8f go.mod, net/dns/recursive: update github.com/miekg/dns
Updates #cleanup

Change-Id: If4de6a84448a17dd81cc2a8af788bd18c3d0bbe3
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2023-06-19 10:32:48 -04:00
Maisem Ali
1302295299 Dockerfile.base: install iputils
Fixes #8361

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-18 07:54:05 -07:00
License Updater
c6794dec11 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-06-17 17:58:29 -07:00
Nick O'Neill
c783f28228 tool/gocross: properly set simulator deployment target (#8355)
Fixes tailscale/corp#11876

Signed-off-by: Nick O'Neill <nick@tailscale.com>
2023-06-15 12:42:30 -07:00
License Updater
c1cbd41fdc licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-06-15 09:22:09 -07:00
Sonia Appasamy
e1cdcf7708 ipn/ipnlocal: add identity headers to HTTP serve proxy
Adds two new headers to HTTP serve proxy:
- `Tailscale-User-Login`: Filled with requester's login name.
- `Tailscale-User-Name`: Filled with requester's display name.

These headers only get filled when the SrcAddr is associated with
a non-tagged (i.e. user-owned) node within the client's Tailnet.

The headers are passed through empty when the request originated
from another tailnet, or the public internet (via funnel).

Updates https://github.com/tailscale/tailscale/issues/6954

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-06-14 14:20:28 -04:00
Claire Wang
80692edcb8 .github/workflows: Add docker build check (#8345)
Fixes #8344

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-06-14 13:49:18 -04:00
Claire Wang
27a0f0a55b Remove unused dependency from dockerfile (#8343)
Closes #8342

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-06-14 13:21:30 -04:00
Andrea Gottardo
99f17a7135 tka: provide verify-deeplink local API endpoint (#8303)
* tka: provide verify-deeplink local API endpoint

Fixes https://github.com/tailscale/tailscale/issues/8302

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>

Address code review comments

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>

Address code review comments by Ross

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>

* Improve error encoding, fix logic error

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>

---------

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
2023-06-13 11:39:23 -07:00
Graham Christensen
4dda949760 tailscale ping: note that -c can take 0 for infinity
Signed-off-by: Graham Christensen <graham@grahamc.com>
2023-06-12 20:57:25 -07:00
Brad Fitzpatrick
a076213f58 net/memnet: add optional Listener.NewConn config knob
Updates tailscale/corp#11620

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-12 14:06:11 -07:00
License Updater
4451a7c364 licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-06-12 13:40:57 -07:00
Maisem Ali
fe95d81b43 ipn/ipnlocal,wgengine/netstack: move LocalBackend specifc serving logic to LocalBackend
The netstack code had a bunch of logic to figure out if the LocalBackend should handle an
incoming connection and then would call the function directly on LocalBackend. Move that
logic to LocalBackend and refactor the methods to return conn handlers.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-12 12:56:07 -07:00
Denton Gentry
5b110685fb wgengine/netstack: increase maxInFlightConnectionAttempts
Address reports of subnet router instability when running in
`--tun=userspace-networking` mode.

Fixes https://github.com/tailscale/corp/issues/12184

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-06-12 12:00:38 -07:00
License Updater
0b3b81b37a licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-06-12 09:28:27 -07:00
dependabot[bot]
6172f9590b .github: Bump golangci/golangci-lint-action from 3.4.0 to 3.6.0
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 3.4.0 to 3.6.0.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](08e2f20817...639cd343e1)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-06-12 06:23:35 -07:00
Brad Fitzpatrick
1543e233e6 net/tstun, tsnet: make happier on WASI
Also fix a js/wasm issue with tsnet in the process. (same issue as WASI)

Updates #8320
Fixes #8315

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-11 18:03:42 -07:00
Flakes Updater
167e154bcc go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-06-11 09:55:48 -07:00
Brad Fitzpatrick
67e912824a all: adjust some build tags for wasi
A start.

Updates #8320

Change-Id: I64057f977be51ba63ce635c56d67de7ecec415d1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-11 09:45:46 -07:00
Charlotte Brandhorst-Satzkorn
63b1a4e35d words: here comes trouble
If you start hearing everything in auto-tune for the rest of the day,
I take no responsibility for it.

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-06-11 08:02:40 -07:00
Andrew Dunham
f077b672e4 net/dns/recursive: add initial implementation of recursive DNS resolver
We've talked in the past about reworking how bootstrap DNS works to
instead do recursive DNS resolution from the root; this would better
support on-prem customers and Headscale users where the DERP servers
don't currently resolve their DNS server. This package is an initial
implementation of recursive resolution for A and AAAA records.

Updates #5853

Change-Id: Ibe974d78709b4b03674b47c4ef61f9a00addf8b4
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2023-06-09 14:11:40 -04:00
Maisem Ali
2e0aa151c9 ssh/tailssh: add support for remote/reverse port forwarding
This basically allows running services on the SSH client and reaching
them from the SSH server during the session.

Updates #6575

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-06-09 08:47:32 -07:00
Andrew Dunham
62130e6b68 util/slicesx: add Partition function
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: If97995ca9ee9fad40f327420dcb1857dd7ea2315
2023-06-08 13:08:51 -04:00
Andrew Dunham
2a9d46c38f wgengine/magicsock: prefer private endpoints to public ones
Switch our best address selection to use a scoring-based approach, where
we boost each address based on whether it's a private IP or IPv6.

For users in cloud environments, this biases endpoint selection towards
using an endpoint that is less likely to cost the user money, and should
be less surprising to users.

This also involves updating the tests to not use private IPv4 addresses;
other than that change, the behaviour should be identical for existing
endpoints.

Updates #8097

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I069e3b399daea28be66b81f7e44fc27b2943d8af
2023-06-08 12:23:28 -04:00
Brad Fitzpatrick
eefee6f149 all: use cmpx.Or where it made sense
I left a few out where writing it explicitly was better
for various reasons.

Updates #8296

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-07 22:06:24 -07:00
phirework
699996ad6c go.toolchain.rev: upgrade to Go 1.20.5 (#8304)
Change-Id: I317b6e61d62212efca0e905ea9c626cc24a6912b

Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-06-07 22:12:18 -04:00
336 changed files with 27927 additions and 8700 deletions

15
.github/workflows/docker-file-build.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: "Dockerfile build"
on:
push:
branches:
- main
pull_request:
branches:
- "*"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: "Build Docker image"
run: docker build .

View File

@@ -32,7 +32,7 @@ jobs:
- name: golangci-lint
# Note: this is the 'v3' tag as of 2023-04-17
uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
with:
version: v1.52.2

37
.github/workflows/govulncheck.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: govulncheck
on:
schedule:
- cron: "0 12 * * *" # 8am EST / 10am PST / 12pm UTC
workflow_dispatch: # allow manual trigger for testing
pull_request:
paths:
- ".github/workflows/govulncheck.yml"
jobs:
source-scan:
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Install govulncheck
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Scan source code for known vulnerabilities
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
- uses: ruby/action-slack@v3.2.1
with:
payload: >
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks>
(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|commit>) of ${{ github.repository }}@${{ github.ref_name }} by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'schedule'

View File

@@ -78,7 +78,7 @@ jobs:
|| contains(matrix.image, 'amazonlinux')
- name: install dependencies (zypper)
# tar and gzip are needed by the actions/checkout below.
run: zypper --non-interactive install tar gzip
run: zypper --non-interactive install tar gzip ${{ matrix.deps }}
if: contains(matrix.image, 'opensuse')
- name: install dependencies (apt-get)
run: |

View File

@@ -90,11 +90,11 @@ jobs:
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: test all
run: ./tool/go test ${{matrix.buildflags}} -exec=/tmp/testwrapper
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
env:
GOARCH: ${{ matrix.goarch }}
- name: bench all
run: ./tool/go test ${{matrix.buildflags}} -exec=/tmp/testwrapper -test.bench=. -test.benchtime=1x -test.run=^$
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$
env:
GOARCH: ${{ matrix.goarch }}
- name: check that no tracked files changed
@@ -144,7 +144,7 @@ jobs:
# Don't use -bench=. -benchtime=1x.
# Somewhere in the layers (powershell?)
# the equals signs cause great confusion.
run: go test -bench . -benchtime 1x ./...
run: go run ./cmd/testwrapper ./... -bench . -benchtime 1x
vm:
runs-on: ["self-hosted", "linux", "vm"]

5
.gitignore vendored
View File

@@ -35,5 +35,10 @@ cmd/tailscaled/tailscaled
# Ignore direnv nix-shell environment cache
.direnv/
# Ignore web client node modules
.vite/
client/web/node_modules
client/web/build
/gocross
/dist

View File

@@ -31,7 +31,7 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.20-alpine AS build-env
FROM golang:1.21-alpine AS build-env
WORKDIR /go/src/tailscale
@@ -47,8 +47,7 @@ RUN go install \
golang.org/x/crypto/ssh \
golang.org/x/crypto/acme \
nhooyr.io/websocket \
github.com/mdlayher/netlink \
golang.zx2c4.com/wireguard/device
github.com/mdlayher/netlink
COPY . .
@@ -73,4 +72,4 @@ RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
COPY --from=build-env /go/bin/* /usr/local/bin/
# For compat with the previous run.sh, although ideally you should be
# using build_docker.sh which sets an entrypoint for the image.
RUN ln -s /usr/local/bin/containerboot /tailscale/run.sh
RUN mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh

View File

@@ -2,4 +2,4 @@
# SPDX-License-Identifier: BSD-3-Clause
FROM alpine:3.16
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables iputils

View File

@@ -37,7 +37,7 @@ not open source.
## Building
We always require the latest Go release, currently Go 1.20. (While we build
We always require the latest Go release, currently Go 1.21. (While we build
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
required.)

View File

@@ -1 +1 @@
1.43.0
1.49.0

39
api.md
View File

@@ -101,8 +101,8 @@ You can also [list all devices in the tailnet](#list-tailnet-devices) to get the
``` jsonc
{
// addresses (array of strings) is a list of Tailscale IP
// addresses for the device, including both ipv4 (formatted as 100.x.y.z)
// and ipv6 (formatted as fd7a:115c:a1e0:a:b:c:d:e) addresses.
// addresses for the device, including both IPv4 (formatted as 100.x.y.z)
// and IPv6 (formatted as fd7a:115c:a1e0:a:b:c:d:e) addresses.
"addresses": [
"100.87.74.78",
"fd7a:115c:a1e0:ac82:4843:ca90:697d:c36e"
@@ -516,7 +516,8 @@ The ID of the device.
#### `authorized` (required in `POST` body)
Specify whether the device is authorized.
Specify whether the device is authorized. False to deauthorize an authorized device, and true to authorize a new device or to re-authorize a previously deauthorized device.
``` jsonc
{
@@ -1114,6 +1115,21 @@ Look at the response body to determine whether there was a problem within your A
}
```
If your tailnet has [user and group provisioning](https://tailscale.com/kb/1180/sso-okta-scim/) turned on, we will also warn you about
any groups that are used in the policy file that are not being synced from SCIM. Explicitly defined groups will not trigger this warning.
```jsonc
{
"message":"warning(s) found",
"data":[
{
"user": "group:unknown@example.com",
"warnings":["group is not syncing from SCIM and will be ignored by rules in the policy file"]
}
]
}
```
<a href="tailnet-devices"></a>
## List tailnet devices
@@ -1222,6 +1238,11 @@ The remaining three methods operate on auth keys and API access tokens.
// expirySeconds (int) is the duration in seconds a new key is valid.
"expirySeconds": 86400
// description (string) is an optional short phrase that describes what
// this key is used for. It can be a maximum of 50 alphanumeric characters.
// Hyphens and underscores are also allowed.
"description": "short description of key purpose"
}
```
@@ -1308,6 +1329,9 @@ Note the following about required vs. optional values:
Specifies the duration in seconds until the key should expire.
Defaults to 90 days if not supplied.
- **`description`:** Optional in `POST` body.
A short string specifying the purpose of the key. Can be a maximum of 50 alphanumeric characters. Hyphens and spaces are also allowed.
### Request example
``` jsonc
@@ -1325,7 +1349,8 @@ curl "https://api.tailscale.com/api/v2/tailnet/example.com/keys" \
}
}
},
"expirySeconds": 86400
"expirySeconds": 86400,
"description": "dev access"
}'
```
@@ -1351,7 +1376,8 @@ It holds the capabilities specified in the request and can no longer be retrieve
"tags": [ "tag:example" ]
}
}
}
},
"description": "dev access"
}
```
@@ -1403,7 +1429,8 @@ The response is a JSON object with information about the key supplied.
]
}
}
}
},
"description": "dev access"
}
```

View File

@@ -150,8 +150,9 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
// ACLTestFailureSummary specifies the JSON format sent to the
// JavaScript client to be rendered in the HTML.
type ACLTestFailureSummary struct {
User string `json:"user"`
Errors []string `json:"errors"`
User string `json:"user,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.

View File

@@ -10,12 +10,14 @@ import "tailscale.com/tailcfg"
const LocalAPIHost = "local-tailscaled.sock"
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
// In successful whois responses, Node and UserProfile are never nil.
type WhoIsResponse struct {
Node *tailcfg.Node
UserProfile *tailcfg.UserProfile
// Caps are extra capabilities that the remote Node has to this node.
Caps []string `json:",omitempty"`
// CapMap is a map of capabilities to their values.
// See tailcfg.PeerCapMap and tailcfg.PeerCapability for details.
CapMap tailcfg.PeerCapMap
}
// FileTarget is a node to which files can be sent, and the PeerAPI

View File

@@ -10,6 +10,7 @@ type DNSConfig struct {
Domains []string `json:"domains"`
Nameservers []string `json:"nameservers"`
Proxied bool `json:"proxied"`
DNSFilterURL string `json:"DNSFilterURL"`
}
type DNSResolver struct {

View File

@@ -259,6 +259,28 @@ func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/metrics")
}
// IncrementCounter increments the value of a Tailscale daemon's counter
// metric by the given delta. If the metric has yet to exist, a new counter
// metric is created and initialized to delta.
//
// IncrementCounter does not support gauge metrics or negative delta values.
func (lc *LocalClient) IncrementCounter(ctx context.Context, name string, delta int) error {
type metricUpdate struct {
Name string `json:"name"`
Type string `json:"type"`
Value int `json:"value"` // amount to increment by
}
if delta < 0 {
return errors.New("negative delta not allowed")
}
_, err := lc.send(ctx, "POST", "/localapi/v0/upload-client-metrics", 200, jsonBody([]metricUpdate{{
Name: name,
Type: "counter",
Value: delta,
}}))
return err
}
// TailDaemonLogs returns a stream the Tailscale daemon's logs as they arrive.
// Close the context to stop the stream.
func (lc *LocalClient) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
@@ -807,11 +829,25 @@ func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn str
return "", false
}
// PingOpts contains options for the ping request.
//
// The zero value is valid, which means to use defaults.
type PingOpts struct {
// Size is the length of the ping message in bytes. It's ignored if it's
// smaller than the minimum message size.
//
// For disco pings, it specifies the length of the packet's payload. That
// is, it includes the disco headers and message, but not the IP and UDP
// headers.
Size int
}
// Ping sends a ping of the provided type to the provided IP and waits
// for its response.
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
// for its response. The opts type specifies additional options.
func (lc *LocalClient) PingWithOpts(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType, opts PingOpts) (*ipnstate.PingResult, error) {
v := url.Values{}
v.Set("ip", ip.String())
v.Set("size", strconv.Itoa(opts.Size))
v.Set("type", string(pingtype))
body, err := lc.send(ctx, "POST", "/localapi/v0/ping?"+v.Encode(), 200, nil)
if err != nil {
@@ -820,6 +856,12 @@ func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg
return decodeJSON[*ipnstate.PingResult](body)
}
// Ping sends a ping of the provided type to the provided IP and waits
// for its response.
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
return lc.PingWithOpts(ctx, ip, pingtype, PingOpts{})
}
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *LocalClient) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
@@ -946,6 +988,57 @@ func (lc *LocalClient) NetworkLockForceLocalDisable(ctx context.Context) error {
return nil
}
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
// in url and returns information extracted from it.
func (lc *LocalClient) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct {
URL string
}{url}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/verify-deeplink", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending verify-deeplink: %w", err)
}
return decodeJSON[*tka.DeeplinkValidationResult](body)
}
// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *LocalClient) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
}{removeKeys, forkFrom.String()}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/generate-recovery-aum", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending generate-recovery-aum: %w", err)
}
return body, nil
}
// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
func (lc *LocalClient) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
return nil, fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return body, nil
}
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *LocalClient) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
return fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return nil
}
// SetServeConfig sets or replaces the serving settings.
// If config is nil, settings are cleared and serving is disabled.
func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
@@ -1073,6 +1166,27 @@ func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID)
return err
}
// QueryFeature makes a request for instructions on how to enable
// a feature, such as Funnel, for the node's tailnet. If relevant,
// this includes a control server URL the user can visit to enable
// the feature.
//
// If you are looking to use QueryFeature, you'll likely want to
// use cli.enableFeatureInteractive instead, which handles the logic
// of wraping QueryFeature and translating its response into an
// interactive flow for the user, including using the IPN notify bus
// to block until the feature has been enabled.
//
// 2023-08-09: Valid feature values are "serve" and "funnel".
func (lc *LocalClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
v := url.Values{"feature": {feature}}
body, err := lc.send(ctx, "POST", "/localapi/v0/query-feature?"+v.Encode(), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*tailcfg.QueryFeatureResponse](body)
}
func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
v := url.Values{"region": {regionIDOrCode}}
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-derp-region?"+v.Encode(), 200, nil)

View File

@@ -1,10 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !go1.20
//go:build !go1.21
package tailscale
func init() {
you_need_Go_1_20_to_compile_Tailscale()
you_need_Go_1_21_to_compile_Tailscale()
}

75
client/web/dev.go Normal file
View File

@@ -0,0 +1,75 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// startDevServer starts the JS dev server that does on-demand rebuilding
// and serving of web client JS and CSS resources.
func (s *Server) startDevServer() (cleanup func()) {
root := gitRootDir()
webClientPath := filepath.Join(root, "client", "web")
yarn := filepath.Join(root, "tool", "yarn")
node := filepath.Join(root, "tool", "node")
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
if err != nil {
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
}
log.Printf("starting JavaScript dev server...")
cmd := exec.Command(node, vite)
cmd.Dir = webClientPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Fatalf("Starting JS dev server: %v", err)
}
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
return func() {
cmd.Process.Signal(os.Interrupt)
err := cmd.Wait()
log.Printf("JavaScript dev server exited: %v", err)
}
}
func (s *Server) addProxyToDevServer() {
if !s.devMode {
return // only using Vite proxy in dev mode
}
// We use Vite to develop on the web client.
// Vite starts up its own local server for development,
// which we proxy requests to from Server.ServeHTTP.
// Here we set up the proxy to Vite's server.
handleErr := func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("The web client development server isn't running. " +
"Run `./tool/yarn --cwd client/web start` from " +
"the repo root to start the development server."))
w.Write([]byte("\n\nError: " + err.Error()))
}
viteTarget, _ := url.Parse("http://127.0.0.1:4000")
s.devProxy = httputil.NewSingleHostReverseProxy(viteTarget)
s.devProxy.ErrorHandler = handleErr
}
func gitRootDir() string {
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
}
return strings.TrimSpace(string(top))
}

29
client/web/index.html Normal file
View File

@@ -0,0 +1,29 @@
<!doctype html>
<html class="bg-gray-50">
<head>
<title>Tailscale</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
<link rel="stylesheet" type="text/css" href="/src/index.css" />
</head>
<body>
<div class="min-h-screen py-10 flex justify-center items-center" style="display: none">
<div class="max-w-md">
<h3 class="font-semibold text-lg mb-4">Your web browser is unsupported.</h3>
<p class="mb-2">
Update to a modern browser to access the Tailscale web client. You can use
<a class="link" href="https://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>,
<a class="link" href="https://www.microsoft.com/en-us/edge" target="_blank">Edge</a>,
<a class="link" href="https://www.apple.com/safari/" target="_blank">Safari</a>,
or <a class="link" href="https://www.google.com/chrome/" target="_blank">Chrome</a>.</p>
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a></p>
</div>
</div>
<noscript>
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
</noscript>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

42
client/web/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "webclient",
"version": "0.0.1",
"license": "BSD-3-Clause",
"engines": {
"node": "18.16.1",
"yarn": "1.22.19"
},
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"postcss": "^8.4.27",
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
"typescript": "^4.7.4",
"vite": "^4.3.9",
"vite-plugin-rewrite-all": "^1.0.1",
"vite-plugin-svgr": "^3.2.0",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^0.32.0"
},
"scripts": {
"build": "vite build",
"start": "vite",
"lint": "tsc --noEmit",
"test": "vitest",
"format": "prettier --write 'src/**/*.{ts,tsx}'",
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
},
"prettier": {
"semi": false,
"printWidth": 80
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,25 @@
import React from "react"
import { Footer, Header, IP, State } from "src/components/legacy"
import useNodeData from "src/hooks/node-data"
export default function App() {
const data = useNodeData()
return (
<div className="py-14">
{!data ? (
// TODO(sonia): add a loading view
<div className="text-center">Loading...</div>
) : (
<>
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} />
<IP data={data} />
<State data={data} />
</main>
<Footer data={data} />
</>
)}
</div>
)
}

View File

@@ -0,0 +1,272 @@
import React from "react"
import { NodeData } from "src/hooks/node-data"
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
// that (crudely) implement the pre-2023 web client. These are implemented
// purely to ease migration to the new React-based web client, and will
// eventually be completely removed.
export function Header(props: { data: NodeData }) {
const { data } = props
return (
<header className="flex justify-between items-center min-width-0 py-2 mb-8">
<svg
width="26"
height="26"
viewBox="0 0 23 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="flex-shrink-0 mr-4"
>
<circle
opacity="0.2"
cx="3.4"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="3.4"
cy="19.5"
r="2.7"
fill="currentColor"
></circle>
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="11.5"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle
opacity="0.2"
cx="19.5"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="19.5"
cy="19.5"
r="2.7"
fill="currentColor"
></circle>
</svg>
<div className="flex items-center justify-end space-x-2 w-2/3">
{data.Profile && (
<>
<div className="text-right w-full leading-4">
<h4 className="truncate leading-normal">
{data.Profile.LoginName}
</h4>
<div className="text-xs text-gray-500 text-right">
<a href="#" className="hover:text-gray-700 js-loginButton">
Switch account
</a>{" "}
|{" "}
<a href="#" className="hover:text-gray-700 js-loginButton">
Reauthenticate
</a>{" "}
|{" "}
<a href="#" className="hover:text-gray-700 js-logoutButton">
Logout
</a>
</div>
</div>
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{data.Profile.ProfilePicURL ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${data.Profile.ProfilePicURL})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
</>
)}
</div>
</header>
)
}
export function IP(props: { data: NodeData }) {
const { data } = props
if (!data.IP) {
return null
}
return (
<>
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
<div className="flex items-center min-width-0">
<svg
className="flex-shrink-0 text-gray-600 mr-3 ml-1"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<div>
<h4 className="font-semibold truncate mr-2">{data.DeviceName}</h4>
</div>
</div>
<h5>{data.IP}</h5>
</div>
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600">
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()}
{data.IsSynology && (
<>
, DSM{data.DSMVersion}
{data.TUNMode || (
<>
{" "}
(
<a
href="https://tailscale.com/kb/1152/synology-outbound/"
className="link-underline text-gray-600"
target="_blank"
aria-label="Configure outbound synology traffic"
rel="noopener noreferrer"
>
outgoing access not configured
</a>
)
</>
)}
</>
)}
</p>
</>
)
}
export function State(props: { data: NodeData }) {
const { data } = props
switch (data.Status) {
case "NeedsLogin":
case "NoState":
if (data.IP) {
return (
<>
<div className="mb-6">
<p className="text-gray-700">
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"
className="link"
target="_blank"
>
learn more
</a>
.
</p>
</div>
<a href="#" className="mb-4 js-loginButton" target="_blank">
<button className="button button-blue w-full">
Reauthenticate
</button>
</a>
</>
)
} else {
return (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
<p className="text-gray-700">
Get started by logging in to your Tailscale network.
Or,&nbsp;learn&nbsp;more at{" "}
<a
href="https://tailscale.com/"
className="link"
target="_blank"
>
tailscale.com
</a>
.
</p>
</div>
<a href="#" className="mb-4 js-loginButton" target="_blank">
<button className="button button-blue w-full">Log In</button>
</a>
</>
)
}
case "NeedsMachineAuth":
return (
<div className="mb-4">
This device is authorized, but needs approval from a network admin
before it can connect to the network.
</div>
)
default:
return (
<>
<div className="mb-4">
<p>
You are connected! Access this device over Tailscale using the
device name or IP address above.
</p>
</div>
<div className="mb-4">
<a href="#" className="mb-4 js-advertiseExitNode">
{data.AdvertiseExitNode ? (
<button
className="button button-red button-medium"
id="enabled"
>
Stop advertising Exit Node
</button>
) : (
<button
className="button button-blue button-medium"
id="enabled"
>
Advertise as Exit Node
</button>
)}
</a>
</div>
</>
)
}
}
export function Footer(props: { data: NodeData }) {
const { data } = props
return (
<footer className="container max-w-lg mx-auto text-center">
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={data.LicensesURL}
>
Open Source Licenses
</a>
</footer>
)
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react"
export type NodeData = {
Profile: UserProfile
Status: string
DeviceName: string
IP: string
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
TUNMode: boolean
IsSynology: boolean
DSMVersion: number
IsUnraid: boolean
UnraidToken: string
IPNVersion: string
}
export type UserProfile = {
LoginName: string
DisplayName: string
ProfilePicURL: string
}
// useNodeData returns basic data about the current node.
export default function useNodeData() {
const [data, setData] = useState<NodeData>()
useEffect(() => {
fetch("/api/data")
.then((response) => response.json())
.then((json) => setData(json))
.catch((error) => console.error(error))
}, [])
return data
}

130
client/web/src/index.css Normal file
View File

@@ -0,0 +1,130 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* Non-Tailwind styles begin here.
*/
.bg-gray-0 {
--tw-bg-opacity: 1;
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
}
.bg-gray-50 {
--tw-bg-opacity: 1;
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
}
html {
letter-spacing: -0.015em;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.link {
--text-opacity: 1;
color: #4b70cc;
color: rgba(75, 112, 204, var(--text-opacity));
}
.link:hover,
.link:active {
--text-opacity: 1;
color: #19224a;
color: rgba(25, 34, 74, var(--text-opacity));
}
.link-underline {
text-decoration: underline;
}
.link-underline:hover,
.link-underline:active {
text-decoration: none;
}
.link-muted {
/* same as text-gray-500 */
--tw-text-opacity: 1;
color: rgba(112, 110, 109, var(--tw-text-opacity));
}
.link-muted:hover,
.link-muted:active {
/* same as text-gray-500 */
--tw-text-opacity: 1;
color: rgba(68, 67, 66, var(--tw-text-opacity));
}
.button {
font-weight: 500;
padding-top: 0.45rem;
padding-bottom: 0.45rem;
padding-left: 1rem;
padding-right: 1rem;
border-radius: 0.375rem;
border-width: 1px;
border-color: transparent;
transition-property: background-color, border-color, color, box-shadow;
transition-duration: 120ms;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
min-width: 80px;
}
.button:focus {
outline: 0;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}
.button:disabled {
cursor: not-allowed;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.button-blue {
--bg-opacity: 1;
background-color: #4b70cc;
background-color: rgba(75, 112, 204, var(--bg-opacity));
--border-opacity: 1;
border-color: #4b70cc;
border-color: rgba(75, 112, 204, var(--border-opacity));
--text-opacity: 1;
color: #fff;
color: rgba(255, 255, 255, var(--text-opacity));
}
.button-blue:enabled:hover {
--bg-opacity: 1;
background-color: #3f5db3;
background-color: rgba(63, 93, 179, var(--bg-opacity));
--border-opacity: 1;
border-color: #3f5db3;
border-color: rgba(63, 93, 179, var(--border-opacity));
}
.button-blue:disabled {
--text-opacity: 1;
color: #cedefd;
color: rgba(206, 222, 253, var(--text-opacity));
--bg-opacity: 1;
background-color: #6c94ec;
background-color: rgba(108, 148, 236, var(--bg-opacity));
--border-opacity: 1;
border-color: #6c94ec;
border-color: rgba(108, 148, 236, var(--border-opacity));
}
.button-red {
background-color: #d04841;
border-color: #d04841;
color: #fff;
}
.button-red:enabled:hover {
background-color: #b22d30;
border-color: #b22d30;
}

16
client/web/src/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react"
import { createRoot } from "react-dom/client"
import App from "src/components/app"
const rootEl = document.createElement("div")
rootEl.id = "app-root"
rootEl.classList.add("relative", "z-0")
document.body.append(rootEl)
const root = createRoot(rootEl)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

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

16
client/web/tsconfig.json Normal file
View File

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

69
client/web/vite.config.ts Normal file
View File

@@ -0,0 +1,69 @@
/// <reference types="vitest" />
import { createLogger, defineConfig } from "vite"
import rewrite from "vite-plugin-rewrite-all"
import svgr from "vite-plugin-svgr"
import paths from "vite-tsconfig-paths"
// Use a custom logger that filters out Vite's logging of server URLs, since
// they are an attractive nuisance (we run a proxy in front of Vite, and the
// tailscale web client should be accessed through that).
// Unfortunately there's no option to disable this logging, so the best we can
// do it to ignore calls from a specific function.
const filteringLogger = createLogger(undefined, { allowClearScreen: false })
const originalInfoLog = filteringLogger.info
filteringLogger.info = (...args) => {
if (new Error("ignored").stack?.includes("printServerUrls")) {
return
}
originalInfoLog.apply(filteringLogger, args)
}
// https://vitejs.dev/config/
export default defineConfig({
base: "/",
plugins: [
paths(),
svgr(),
// By default, the Vite dev server doesn't handle dots
// in path names and treats them as static files.
// This plugin changes Vite's routing logic to fix this.
// See: https://github.com/vitejs/vite/issues/2415
rewrite(),
],
build: {
outDir: "build",
sourcemap: true,
},
esbuild: {
logOverride: {
// Silence a warning about `this` being undefined in ESM when at the
// top-level. The way JSX is transpiled causes this to happen, but it
// isn't a problem.
// See: https://github.com/vitejs/vite/issues/8644
"this-is-undefined-in-esm": "silent",
},
},
server: {
// This needs to be 127.0.0.1 instead of localhost, because of how our
// Go proxy connects to it.
host: "127.0.0.1",
// If you change the port, be sure to update the proxy in adminhttp.go too.
port: 4000,
// Don't proxy the WebSocket connection used for live reloading by running
// it on a separate port.
hmr: {
protocol: "ws",
port: 4001,
},
},
test: {
exclude: ["**/node_modules/**", "**/dist/**"],
testTimeout: 20000,
environment: "jsdom",
deps: {
inline: ["date-fns", /\.wasm\?url$/],
},
},
clearScreen: false,
customLogger: filteringLogger,
})

529
client/web/web.go Normal file
View File

@@ -0,0 +1,529 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package web provides the Tailscale client for web.
package web
import (
"bytes"
"context"
"crypto/tls"
_ "embed"
"encoding/json"
"encoding/xml"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"os/exec"
"strings"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/licenses"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/util/groupmember"
"tailscale.com/util/httpm"
"tailscale.com/version/distro"
)
//go:embed web.html
var webHTML string
//go:embed web.css
var webCSS string
//go:embed auth-redirect.html
var authenticationRedirectHTML string
var tmpl *template.Template
// Server is the backend server for a Tailscale web client.
type Server struct {
lc *tailscale.LocalClient
devMode bool
devProxy *httputil.ReverseProxy // only filled when devMode is on
}
// NewServer constructs a new Tailscale web client server.
//
// lc is an optional parameter. When not filled, NewServer
// initializes its own tailscale.LocalClient.
func NewServer(devMode bool, lc *tailscale.LocalClient) (s *Server, cleanup func()) {
if lc == nil {
lc = &tailscale.LocalClient{}
}
s = &Server{
devMode: devMode,
lc: lc,
}
cleanup = func() {}
if s.devMode {
cleanup = s.startDevServer()
s.addProxyToDevServer()
}
return s, cleanup
}
func init() {
tmpl = template.Must(template.New("web.html").Parse(webHTML))
template.Must(tmpl.New("web.css").Parse(webCSS))
}
// authorize returns the name of the user accessing the web UI after verifying
// whether the user has access to the web UI. The function will write the
// error to the provided http.ResponseWriter.
// Note: This is different from a tailscale user, and is typically the local
// user on the node.
func authorize(w http.ResponseWriter, r *http.Request) (string, error) {
switch distro.Get() {
case distro.Synology:
user, err := synoAuthn()
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return "", err
}
if err := authorizeSynology(user); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return "", err
}
return user, nil
case distro.QNAP:
user, resp, err := qnapAuthn(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return "", err
}
if resp.IsAdmin == 0 {
http.Error(w, err.Error(), http.StatusForbidden)
return "", err
}
return user, nil
}
return "", nil
}
// authorizeSynology checks whether the provided user has access to the web UI
// by consulting the membership of the "administrators" group.
func authorizeSynology(name string) error {
yes, err := groupmember.IsMemberOfGroup("administrators", name)
if err != nil {
return err
}
if !yes {
return fmt.Errorf("not a member of administrators group")
}
return nil
}
type qnapAuthResponse struct {
AuthPassed int `xml:"authPassed"`
IsAdmin int `xml:"isAdmin"`
AuthSID string `xml:"authSid"`
ErrorValue int `xml:"errorValue"`
}
func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
user, err := r.Cookie("NAS_USER")
if err != nil {
return "", nil, err
}
token, err := r.Cookie("qtoken")
if err == nil {
return qnapAuthnQtoken(r, user.Value, token.Value)
}
sid, err := r.Cookie("NAS_SID")
if err == nil {
return qnapAuthnSid(r, user.Value, sid.Value)
}
return "", nil, fmt.Errorf("not authenticated by any mechanism")
}
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
// running based on the request URL. This is necessary because QNAP has so
// many options, see https://github.com/tailscale/tailscale/issues/7108
// and https://github.com/tailscale/tailscale/issues/6903
func qnapAuthnURL(requestUrl string, query url.Values) string {
in, err := url.Parse(requestUrl)
scheme := ""
host := ""
if err != nil || in.Scheme == "" {
log.Printf("Cannot parse QNAP login URL %v", err)
// try localhost and hope for the best
scheme = "http"
host = "localhost"
} else {
scheme = in.Scheme
host = in.Host
}
u := url.URL{
Scheme: scheme,
Host: host,
Path: "/cgi-bin/authLogin.cgi",
RawQuery: query.Encode(),
}
return u.String()
}
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
query := url.Values{
"qtoken": []string{token},
"user": []string{user},
}
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
}
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
query := url.Values{
"sid": []string{sid},
}
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
}
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
// SAN. See https://github.com/tailscale/tailscale/issues/6903
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
resp, err := client.Get(url)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, err
}
authResp := &qnapAuthResponse{}
if err := xml.Unmarshal(out, authResp); err != nil {
return "", nil, err
}
if authResp.AuthPassed == 0 {
return "", nil, fmt.Errorf("not authenticated")
}
return user, authResp, nil
}
func synoAuthn() (string, error) {
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("auth: %v: %s", err, out)
}
return strings.TrimSpace(string(out)), nil
}
func authRedirect(w http.ResponseWriter, r *http.Request) bool {
if distro.Get() == distro.Synology {
return synoTokenRedirect(w, r)
}
return false
}
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" {
return false
}
if r.URL.Query().Get("SynoToken") != "" {
return false
}
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
return false
}
// We need a SynoToken for authenticate.cgi.
// So we tell the client to get one.
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
return true
}
const synoTokenRedirectHTML = `<html><body>
Redirecting with session token...
<script>
var serverURL = window.location.protocol + "//" + window.location.host;
var req = new XMLHttpRequest();
req.overrideMimeType("application/json");
req.open("GET", serverURL + "/webman/login.cgi", true);
req.onload = function() {
var jsonResponse = JSON.parse(req.responseText);
var token = jsonResponse["SynoToken"];
document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
};
req.send(null);
</script>
</body></html>
`
// ServeHTTP processes all requests for the Tailscale web client.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.devMode {
if r.URL.Path == "/api/data" {
user, err := authorize(w, r)
if err != nil {
return
}
switch r.Method {
case httpm.GET:
s.serveGetNodeDataJSON(w, r, user)
case httpm.POST:
s.servePostNodeUpdate(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
// When in dev mode, proxy to the Vite dev server.
s.devProxy.ServeHTTP(w, r)
return
}
if authRedirect(w, r) {
return
}
user, err := authorize(w, r)
if err != nil {
return
}
switch {
case r.URL.Path == "/redirect" || r.URL.Path == "/redirect/":
io.WriteString(w, authenticationRedirectHTML)
return
case r.Method == "POST":
s.servePostNodeUpdate(w, r)
return
default:
s.serveGetNodeData(w, r, user)
return
}
}
type nodeData struct {
Profile tailcfg.UserProfile
SynologyUser string
Status string
DeviceName string
IP string
AdvertiseExitNode bool
AdvertiseRoutes string
LicensesURL string
TUNMode bool
IsSynology bool
DSMVersion int // 6 or 7, if IsSynology=true
IsUnraid bool
UnraidToken string
IPNVersion string
}
func (s *Server) getNodeData(ctx context.Context, user string) (*nodeData, error) {
st, err := s.lc.Status(ctx)
if err != nil {
return nil, err
}
prefs, err := s.lc.GetPrefs(ctx)
if err != nil {
return nil, err
}
profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0]
versionShort := strings.Split(st.Version, "-")[0]
data := &nodeData{
SynologyUser: user,
Profile: profile,
Status: st.BackendState,
DeviceName: deviceName,
LicensesURL: licenses.LicensesURL(),
TUNMode: st.TUN,
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
DSMVersion: distro.DSMVersion(),
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
IPNVersion: versionShort,
}
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertiseExitNode = true
} else {
if data.AdvertiseRoutes != "" {
data.AdvertiseRoutes += ","
}
data.AdvertiseRoutes += r.String()
}
}
if len(st.TailscaleIPs) != 0 {
data.IP = st.TailscaleIPs[0].String()
}
return data, nil
}
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request, user string) {
data, err := s.getNodeData(r.Context(), user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
buf := new(bytes.Buffer)
if err := tmpl.Execute(buf, *data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(buf.Bytes())
}
func (s *Server) serveGetNodeDataJSON(w http.ResponseWriter, r *http.Request, user string) {
data, err := s.getNodeData(r.Context(), user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(*data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
}
type nodeUpdate struct {
AdvertiseRoutes string
AdvertiseExitNode bool
Reauthenticate bool
ForceLogout bool
}
func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
st, err := s.lc.Status(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var postData nodeUpdate
type mi map[string]any
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
w.WriteHeader(400)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
mp := &ipn.MaskedPrefs{
AdvertiseRoutesSet: true,
WantRunningSet: true,
}
mp.Prefs.WantRunning = true
mp.Prefs.AdvertiseRoutes = routes
log.Printf("Doing edit: %v", mp.Pretty())
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
var reauth, logout bool
if postData.Reauthenticate {
reauth = true
}
if postData.ForceLogout {
logout = true
}
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
url, err := s.tailscaleUp(r.Context(), st, postData)
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
if url != "" {
json.NewEncoder(w).Encode(mi{"url": url})
} else {
io.WriteString(w, "{}")
}
return
}
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) {
if postData.ForceLogout {
if err := s.lc.Logout(ctx); err != nil {
return "", fmt.Errorf("Logout error: %w", err)
}
return "", nil
}
origAuthURL := st.AuthURL
isRunning := st.BackendState == ipn.Running.String()
forceReauth := postData.Reauthenticate
if !forceReauth {
if origAuthURL != "" {
return origAuthURL, nil
}
if isRunning {
return "", nil
}
}
// printAuthURL reports whether we should print out the
// provided auth URL from an IPN notify.
printAuthURL := func(url string) bool {
return url != origAuthURL
}
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
watcher, err := s.lc.WatchIPNBus(watchCtx, 0)
if err != nil {
return "", err
}
defer watcher.Close()
go func() {
if !isRunning {
s.lc.Start(ctx, ipn.Options{})
}
if forceReauth {
s.lc.StartLoginInteractive(ctx)
}
}()
for {
n, err := watcher.Next()
if err != nil {
return "", err
}
if n.ErrMessage != nil {
msg := *n.ErrMessage
return "", fmt.Errorf("backend error: %v", msg)
}
if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
return *url, nil
}
}
}

64
client/web/web_test.go Normal file
View File

@@ -0,0 +1,64 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"net/url"
"testing"
)
func TestQnapAuthnURL(t *testing.T) {
query := url.Values{
"qtoken": []string{"token"},
}
tests := []struct {
name string
in string
want string
}{
{
name: "localhost http",
in: "http://localhost:8088/",
want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "localhost https",
in: "https://localhost:5000/",
want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "IP http",
in: "http://10.1.20.4:80/",
want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "IP6 https",
in: "https://[ff7d:0:1:2::1]/",
want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "hostname https",
in: "https://qnap.example.com/",
want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "invalid URL",
in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.",
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "err != nil",
in: "http://192.168.0.%31/",
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u := qnapAuthnURL(tt.in, query)
if u != tt.want {
t.Errorf("expected url: %q, got: %q", tt.want, u)
}
})
}
}

1806
client/web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,998 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package clientupdate implements tailscale client update for all supported
// platforms. This package can be used from both tailscaled and tailscale
// binaries.
package clientupdate
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"tailscale.com/hostinfo"
"tailscale.com/net/tshttpproxy"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/must"
"tailscale.com/util/winutil"
"tailscale.com/version"
"tailscale.com/version/distro"
)
const (
CurrentTrack = ""
StableTrack = "stable"
UnstableTrack = "unstable"
)
func versionToTrack(v string) (string, error) {
_, rest, ok := strings.Cut(v, ".")
if !ok {
return "", fmt.Errorf("malformed version %q", v)
}
minorStr, _, ok := strings.Cut(rest, ".")
if !ok {
return "", fmt.Errorf("malformed version %q", v)
}
minor, err := strconv.Atoi(minorStr)
if err != nil {
return "", fmt.Errorf("malformed version %q", v)
}
if minor%2 == 0 {
return "stable", nil
}
return "unstable", nil
}
type updater struct {
UpdateArgs
track string
update func() error
}
// UpdateArgs contains arguments needed to run an update.
type UpdateArgs struct {
// Version can be a specific version number or one of the predefined track
// constants:
//
// - CurrentTrack will use the latest version from the same track as the
// running binary
// - StableTrack and UnstableTrack will use the latest versions of the
// corresponding tracks
//
// Leaving this empty is the same as using CurrentTrack.
Version string
// AppStore forces a local app store check, even if the current binary was
// not installed via an app store.
AppStore bool
// Logf is a logger for update progress messages.
Logf logger.Logf
// Confirm is called when a new version is available and should return true
// if this new version should be installed. When Confirm returns false, the
// update is aborted.
Confirm func(newVer string) bool
}
func (args UpdateArgs) validate() error {
if args.Confirm == nil {
return errors.New("missing Confirm callback in UpdateArgs")
}
if args.Logf == nil {
return errors.New("missing Logf callback in UpdateArgs")
}
return nil
}
// Update runs a single update attempt using the platform-specific mechanism.
//
// On Windows, this copies the calling binary and re-executes it to apply the
// update. The calling binary should handle an "update" subcommand and call
// this function again for the re-executed binary to proceed.
func Update(args UpdateArgs) error {
if err := args.validate(); err != nil {
return err
}
up := &updater{
UpdateArgs: args,
}
switch up.Version {
case StableTrack, UnstableTrack:
up.track = up.Version
case CurrentTrack:
if version.IsUnstableBuild() {
up.track = UnstableTrack
} else {
up.track = StableTrack
}
default:
var err error
up.track, err = versionToTrack(args.Version)
if err != nil {
return err
}
}
switch runtime.GOOS {
case "windows":
up.update = up.updateWindows
case "linux":
switch distro.Get() {
case distro.Synology:
up.update = up.updateSynology
case distro.Debian: // includes Ubuntu
up.update = up.updateDebLike
case distro.Arch:
up.update = up.updateArchLike
case distro.Alpine:
up.update = up.updateAlpineLike
}
switch {
case haveExecutable("pacman"):
up.update = up.updateArchLike
case haveExecutable("apt-get"): // TODO(awly): add support for "apt"
// The distro.Debian switch case above should catch most apt-based
// systems, but add this fallback just in case.
up.update = up.updateDebLike
case haveExecutable("dnf"):
up.update = up.updateFedoraLike("dnf")
case haveExecutable("yum"):
up.update = up.updateFedoraLike("yum")
case haveExecutable("apk"):
up.update = up.updateAlpineLike
}
case "darwin":
switch {
case !args.AppStore && !version.IsSandboxedMacOS():
return errors.ErrUnsupported
case !args.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
up.update = up.updateMacSys
default:
up.update = up.updateMacAppStore
}
case "freebsd":
up.update = up.updateFreeBSD
}
if up.update == nil {
return errors.ErrUnsupported
}
return up.update()
}
func (up *updater) confirm(ver string) bool {
if version.Short() == ver {
up.Logf("already running %v; no update needed", ver)
return false
}
if up.Confirm != nil {
return up.Confirm(ver)
}
return true
}
func (up *updater) updateSynology() error {
if up.Version != "" {
return errors.New("installing a specific version on Synology is not supported")
}
// Get the latest version and list of SPKs from pkgs.tailscale.com.
osName := fmt.Sprintf("dsm%d", distro.DSMVersion())
arch, err := synoArch(hostinfo.New())
if err != nil {
return err
}
latest, err := latestPackages(up.track)
if err != nil {
return err
}
if latest.Version == "" {
return fmt.Errorf("no latest version found for %q track", up.track)
}
spkName := latest.SPKs[osName][arch]
if spkName == "" {
return fmt.Errorf("cannot find Synology package for os=%s arch=%s, please report a bug with your device model", osName, arch)
}
if !up.confirm(latest.Version) {
return nil
}
if err := requireRoot(); err != nil {
return err
}
// Download the SPK into a temporary directory.
spkDir, err := os.MkdirTemp("", "tailscale-update")
if err != nil {
return err
}
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/%s", up.track, spkName)
spkPath := filepath.Join(spkDir, path.Base(url))
// TODO(awly): we should sign SPKs and validate signatures here too.
if err := up.downloadURLToFile(url, spkPath); err != nil {
return err
}
// Install the SPK. Run via nohup to allow install to succeed when we're
// connected over tailscale ssh and this parent process dies. Otherwise, if
// you abort synopkg install mid-way, tailscaled is not restarted.
cmd := exec.Command("nohup", "synopkg", "install", spkPath)
// Don't attach cmd.Stdout to os.Stdout because nohup will redirect that
// into nohup.out file. synopkg doesn't have any progress output anyway, it
// just spits out a JSON result when done.
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("synopkg install failed: %w\noutput:\n%s", err, out)
}
return nil
}
// synoArch returns the Synology CPU architecture matching one of the SPK
// architectures served from pkgs.tailscale.com.
func synoArch(hinfo *tailcfg.Hostinfo) (string, error) {
// Most Synology boxes just use a different arch name from GOARCH.
arch := map[string]string{
"amd64": "x86_64",
"386": "i686",
"arm64": "armv8",
}[hinfo.GoArch]
// Here's the fun part, some older ARM boxes require you to use SPKs
// specifically for their CPU.
//
// See https://github.com/SynoCommunity/spksrc/wiki/Synology-and-SynoCommunity-Package-Architectures
// for a complete list. Here, we override GOARCH for those older boxes that
// support at least DSM6.
//
// This is an artisanal hand-crafted list based on the wiki page. Some
// values may be wrong, since we don't have all those devices to actually
// test with.
switch hinfo.DeviceModel {
case "DS213air", "DS213", "DS413j",
"DS112", "DS112+", "DS212", "DS212+", "RS212", "RS812", "DS212j", "DS112j",
"DS111", "DS211", "DS211+", "DS411slim", "DS411", "RS411", "DS211j", "DS411j":
arch = "88f6281"
case "NVR1218", "NVR216", "VS960HD", "VS360HD":
arch = "hi3535"
case "DS1517", "DS1817", "DS416", "DS2015xs", "DS715", "DS1515", "DS215+":
arch = "alpine"
case "DS216se", "DS115j", "DS114", "DS214se", "DS414slim", "RS214", "DS14", "EDS14", "DS213j":
arch = "armada370"
case "DS115", "DS215j":
arch = "armada375"
case "DS419slim", "DS218j", "RS217", "DS116", "DS216j", "DS216", "DS416slim", "RS816", "DS416j":
arch = "armada38x"
case "RS815", "DS214", "DS214+", "DS414", "RS814":
arch = "armadaxp"
case "DS414j":
arch = "comcerto2k"
case "DS216play":
arch = "monaco"
}
if arch == "" {
return "", fmt.Errorf("cannot determine CPU architecture for Synology model %q (Go arch %q), please report a bug at https://github.com/tailscale/tailscale/issues/new/choose", hinfo.DeviceModel, hinfo.GoArch)
}
return arch, nil
}
func (up *updater) updateDebLike() error {
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
if err := requireRoot(); err != nil {
return err
}
if updated, err := updateDebianAptSourcesList(up.track); err != nil {
return err
} else if updated {
up.Logf("Updated %s to use the %s track", aptSourcesFile, up.track)
}
cmd := exec.Command("apt-get", "update",
// Only update the tailscale repo, not the other ones, treating
// the tailscale.list file as the main "sources.list" file.
"-o", "Dir::Etc::SourceList=sources.list.d/tailscale.list",
// Disable the "sources.list.d" directory:
"-o", "Dir::Etc::SourceParts=-",
// Don't forget about packages in the other repos just because
// we're not updating them:
"-o", "APT::Get::List-Cleanup=0",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return nil
}
const aptSourcesFile = "/etc/apt/sources.list.d/tailscale.list"
// updateDebianAptSourcesList updates the /etc/apt/sources.list.d/tailscale.list
// file to make sure it has the provided track (stable or unstable) in it.
//
// If it already has the right track (including containing both stable and
// unstable), it does nothing.
func updateDebianAptSourcesList(dstTrack string) (rewrote bool, err error) {
was, err := os.ReadFile(aptSourcesFile)
if err != nil {
return false, err
}
newContent, err := updateDebianAptSourcesListBytes(was, dstTrack)
if err != nil {
return false, err
}
if bytes.Equal(was, newContent) {
return false, nil
}
return true, os.WriteFile(aptSourcesFile, newContent, 0644)
}
func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []byte, err error) {
trackURLPrefix := []byte("https://pkgs.tailscale.com/" + dstTrack + "/")
var buf bytes.Buffer
var changes int
bs := bufio.NewScanner(bytes.NewReader(was))
hadCorrect := false
commentLine := regexp.MustCompile(`^\s*\#`)
pkgsURL := regexp.MustCompile(`\bhttps://pkgs\.tailscale\.com/((un)?stable)/`)
for bs.Scan() {
line := bs.Bytes()
if !commentLine.Match(line) {
line = pkgsURL.ReplaceAllFunc(line, func(m []byte) []byte {
if bytes.Equal(m, trackURLPrefix) {
hadCorrect = true
} else {
changes++
}
return trackURLPrefix
})
}
buf.Write(line)
buf.WriteByte('\n')
}
if hadCorrect || (changes == 1 && bytes.Equal(bytes.TrimSpace(was), bytes.TrimSpace(buf.Bytes()))) {
// Unchanged or close enough.
return was, nil
}
if changes != 1 {
// No changes, or an unexpected number of changes (what?). Bail.
// They probably editted it by hand and we don't know what to do.
return nil, fmt.Errorf("unexpected/unsupported %s contents", aptSourcesFile)
}
return buf.Bytes(), nil
}
func (up *updater) updateArchLike() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on Arch-based distros is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "pacman --sync --refresh tailscale"`, err)
}
}()
out, err := exec.Command("pacman", "--sync", "--refresh", "--info", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking pacman for latest tailscale version: %w, output: %q", err, out)
}
ver, err := parsePacmanVersion(out)
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
cmd := exec.Command("pacman", "--sync", "--noconfirm", "tailscale")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using pacman: %w", err)
}
return nil
}
func parsePacmanVersion(out []byte) (string, error) {
for _, line := range strings.Split(string(out), "\n") {
// The line we're looking for looks like this:
// Version : 1.44.2-1
if !strings.HasPrefix(line, "Version") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
}
ver := strings.TrimSpace(parts[1])
// Trim the Arch patch version.
ver = strings.Split(ver, "-")[0]
if ver == "" {
return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
}
return ver, nil
}
return "", fmt.Errorf("could not find latest version of tailscale via pacman")
}
const yumRepoConfigFile = "/etc/yum.repos.d/tailscale.repo"
// updateFedoraLike updates tailscale on any distros in the Fedora family,
// specifically anything that uses "dnf" or "yum" package managers. The actual
// package manager is passed via packageManager.
func (up *updater) updateFedoraLike(packageManager string) func() error {
return func() (err error) {
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "%s upgrade tailscale"`, err, packageManager)
}
}()
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.track); err != nil {
return err
} else if updated {
up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.track)
}
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return nil
}
}
// updateYUMRepoTrack updates the repoFile file to make sure it has the
// provided track (stable or unstable) in it.
func updateYUMRepoTrack(repoFile, dstTrack string) (rewrote bool, err error) {
was, err := os.ReadFile(repoFile)
if err != nil {
return false, err
}
urlRe := regexp.MustCompile(`^(baseurl|gpgkey)=https://pkgs\.tailscale\.com/(un)?stable/`)
urlReplacement := fmt.Sprintf("$1=https://pkgs.tailscale.com/%s/", dstTrack)
s := bufio.NewScanner(bytes.NewReader(was))
newContent := bytes.NewBuffer(make([]byte, 0, len(was)))
for s.Scan() {
line := s.Text()
// Handle repo section name, like "[tailscale-stable]".
if len(line) > 0 && line[0] == '[' {
if !strings.HasPrefix(line, "[tailscale-") {
return false, fmt.Errorf("%q does not look like a tailscale repo file, it contains an unexpected %q section", repoFile, line)
}
fmt.Fprintf(newContent, "[tailscale-%s]\n", dstTrack)
continue
}
// Update the track mentioned in repo name.
if strings.HasPrefix(line, "name=") {
fmt.Fprintf(newContent, "name=Tailscale %s\n", dstTrack)
continue
}
// Update the actual repo URLs.
if strings.HasPrefix(line, "baseurl=") || strings.HasPrefix(line, "gpgkey=") {
fmt.Fprintln(newContent, urlRe.ReplaceAllString(line, urlReplacement))
continue
}
fmt.Fprintln(newContent, line)
}
if bytes.Equal(was, newContent.Bytes()) {
return false, nil
}
return true, os.WriteFile(repoFile, newContent.Bytes(), 0644)
}
func (up *updater) updateAlpineLike() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on Alpine-based distros is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "apk upgrade tailscale"`, err)
}
}()
out, err := exec.Command("apk", "update").CombinedOutput()
if err != nil {
return fmt.Errorf("failed refresh apk repository indexes: %w, output: %q", err, out)
}
out, err = exec.Command("apk", "info", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking apk for latest tailscale version: %w, output: %q", err, out)
}
ver, err := parseAlpinePackageVersion(out)
if err != nil {
return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
}
if !up.confirm(ver) {
return nil
}
cmd := exec.Command("apk", "upgrade", "tailscale")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using apk: %w", err)
}
return nil
}
func parseAlpinePackageVersion(out []byte) (string, error) {
s := bufio.NewScanner(bytes.NewReader(out))
for s.Scan() {
// The line should look like this:
// tailscale-1.44.2-r0 description:
line := strings.TrimSpace(s.Text())
if !strings.HasPrefix(line, "tailscale-") {
continue
}
parts := strings.SplitN(line, "-", 3)
if len(parts) < 3 {
return "", fmt.Errorf("malformed info line: %q", line)
}
return parts[1], nil
}
return "", errors.New("tailscale version not found in output")
}
func (up *updater) updateMacSys() error {
return errors.New("NOTREACHED: On MacSys builds, `tailscale update` is handled in Swift to launch the GUI updater")
}
func (up *updater) updateMacAppStore() error {
out, err := exec.Command("defaults", "read", "/Library/Preferences/com.apple.commerce.plist", "AutoUpdate").CombinedOutput()
if err != nil {
return fmt.Errorf("can't check App Store auto-update setting: %w, output: %q", err, string(out))
}
const on = "1\n"
if string(out) != on {
up.Logf("NOTE: Automatic updating for App Store apps is turned off. You can change this setting in System Settings (search for update).")
}
out, err = exec.Command("softwareupdate", "--list").CombinedOutput()
if err != nil {
return fmt.Errorf("can't check App Store for available updates: %w, output: %q", err, string(out))
}
newTailscale := parseSoftwareupdateList(out)
if newTailscale == "" {
up.Logf("no Tailscale update available")
return nil
}
newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
if !up.confirm(newTailscaleVer) {
return nil
}
cmd := exec.Command("sudo", "softwareupdate", "--install", newTailscale)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("can't install App Store update for Tailscale: %w", err)
}
return nil
}
var macOSAppStoreListPattern = regexp.MustCompile(`(?m)^\s+\*\s+Label:\s*(Tailscale-\d[\d\.]+)`)
// parseSoftwareupdateList searches the output of `softwareupdate --list` on
// Darwin and returns the matching Tailscale package label. If there is none,
// returns the empty string.
//
// See TestParseSoftwareupdateList for example inputs.
func parseSoftwareupdateList(stdout []byte) string {
matches := macOSAppStoreListPattern.FindSubmatch(stdout)
if len(matches) < 2 {
return ""
}
return string(matches[1])
}
// winMSIEnv is the environment variable that, if set, is the MSI file for the
// update command to install. It's passed like this so we can stop the
// tailscale.exe process from running before the msiexec process runs and tries
// to overwrite ourselves.
const winMSIEnv = "TS_UPDATE_WIN_MSI"
var (
verifyAuthenticode func(string) error // or nil on non-Windows
markTempFileFunc func(string) error // or nil on non-Windows
)
func (up *updater) updateWindows() error {
if msi := os.Getenv(winMSIEnv); msi != "" {
up.Logf("installing %v ...", msi)
if err := up.installMSI(msi); err != nil {
up.Logf("MSI install failed: %v", err)
return err
}
up.Logf("success.")
return nil
}
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
arch := runtime.GOARCH
if arch == "386" {
arch = "x86"
}
if !up.confirm(ver) {
return nil
}
if !winutil.IsCurrentProcessElevated() {
return errors.New("must be run as Administrator")
}
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
msiDir := filepath.Join(tsDir, "MSICache")
if fi, err := os.Stat(tsDir); err != nil {
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
} else if !fi.IsDir() {
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
}
if err := os.MkdirAll(msiDir, 0700); err != nil {
return err
}
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
msiTarget := filepath.Join(msiDir, path.Base(url))
if err := up.downloadURLToFile(url, msiTarget); err != nil {
return err
}
up.Logf("verifying MSI authenticode...")
if err := verifyAuthenticode(msiTarget); err != nil {
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
}
up.Logf("authenticode verification succeeded")
up.Logf("making tailscale.exe copy to switch to...")
selfCopy, err := makeSelfCopy()
if err != nil {
return err
}
defer os.Remove(selfCopy)
up.Logf("running tailscale.exe copy for final install...")
cmd := exec.Command(selfCopy, "update")
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
return err
}
// Once it's started, exit ourselves, so the binary is free
// to be replaced.
os.Exit(0)
panic("unreachable")
}
func (up *updater) installMSI(msi string) error {
var err error
for tries := 0; tries < 2; tries++ {
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
cmd.Dir = filepath.Dir(msi)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
if err == nil {
break
}
uninstallVersion := version.Short()
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
uninstallVersion = v
}
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
up.Logf("msiexec uninstall: %v", err)
}
return err
}
func msiUUIDForVersion(ver string) string {
arch := runtime.GOARCH
if arch == "386" {
arch = "x86"
}
track, err := versionToTrack(ver)
if err != nil {
track = UnstableTrack
}
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
}
func makeSelfCopy() (tmpPathExe string, err error) {
selfExe, err := os.Executable()
if err != nil {
return "", err
}
f, err := os.Open(selfExe)
if err != nil {
return "", err
}
defer f.Close()
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
if err != nil {
return "", err
}
if f := markTempFileFunc; f != nil {
if err := f(f2.Name()); err != nil {
return "", err
}
}
if _, err := io.Copy(f2, f); err != nil {
f2.Close()
return "", err
}
return f2.Name(), f2.Close()
}
func (up *updater) downloadURLToFile(urlSrc, fileDst string) (ret error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
defer tr.CloseIdleConnections()
c := &http.Client{Transport: tr}
quickCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
headReq := must.Get(http.NewRequestWithContext(quickCtx, "HEAD", urlSrc, nil))
res, err := c.Do(headReq)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("HEAD %s: %v", urlSrc, res.Status)
}
if res.ContentLength <= 0 {
return fmt.Errorf("HEAD %s: unexpected Content-Length %v", urlSrc, res.ContentLength)
}
up.Logf("Download size: %v", res.ContentLength)
hashReq := must.Get(http.NewRequestWithContext(quickCtx, "GET", urlSrc+".sha256", nil))
hashRes, err := c.Do(hashReq)
if err != nil {
return err
}
hashHex, err := io.ReadAll(io.LimitReader(hashRes.Body, 100))
hashRes.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s.sha256: %v", urlSrc, res.Status)
}
if err != nil {
return err
}
wantHash, err := hex.DecodeString(string(strings.TrimSpace(string(hashHex))))
if err != nil {
return err
}
hash := sha256.New()
dlReq := must.Get(http.NewRequestWithContext(context.Background(), "GET", urlSrc, nil))
dlRes, err := c.Do(dlReq)
if err != nil {
return err
}
// TODO(bradfitz): resume from existing partial file on disk
if dlRes.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: %v", urlSrc, dlRes.Status)
}
of, err := os.Create(fileDst)
if err != nil {
return err
}
defer func() {
if ret != nil {
of.Close()
// TODO(bradfitz): os.Remove(fileDst) too? or keep it to resume from/debug later.
}
}()
pw := &progressWriter{total: res.ContentLength, logf: up.Logf}
n, err := io.Copy(io.MultiWriter(hash, of, pw), io.LimitReader(dlRes.Body, res.ContentLength))
if err != nil {
return err
}
if n != res.ContentLength {
return fmt.Errorf("downloaded %v; want %v", n, res.ContentLength)
}
if err := of.Close(); err != nil {
return err
}
pw.print()
if !bytes.Equal(hash.Sum(nil), wantHash) {
return fmt.Errorf("SHA-256 of downloaded MSI didn't match expected value")
}
up.Logf("hash matched")
return nil
}
type progressWriter struct {
done int64
total int64
lastPrint time.Time
logf logger.Logf
}
func (pw *progressWriter) Write(p []byte) (n int, err error) {
pw.done += int64(len(p))
if time.Since(pw.lastPrint) > 2*time.Second {
pw.print()
}
return len(p), nil
}
func (pw *progressWriter) print() {
pw.lastPrint = time.Now()
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
}
func (up *updater) updateFreeBSD() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on FreeBSD is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "pkg upgrade tailscale"`, err)
}
}()
out, err := exec.Command("pkg", "update").CombinedOutput()
if err != nil {
return fmt.Errorf("failed refresh pkg repository indexes: %w, output: %q", err, out)
}
out, err = exec.Command("pkg", "rquery", "%v", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output: %q", err, out)
}
ver := string(bytes.TrimSpace(out))
if !up.confirm(ver) {
return nil
}
cmd := exec.Command("pkg", "upgrade", "tailscale")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using pkg: %w", err)
}
return nil
}
func haveExecutable(name string) bool {
path, err := exec.LookPath(name)
return err == nil && path != ""
}
func requestedTailscaleVersion(ver, track string) (string, error) {
if ver != "" {
return ver, nil
}
return LatestTailscaleVersion(track)
}
// LatestTailscaleVersion returns the latest released version for the given
// track from pkgs.tailscale.com.
func LatestTailscaleVersion(track string) (string, error) {
if track == CurrentTrack {
if version.IsUnstableBuild() {
track = UnstableTrack
} else {
track = StableTrack
}
}
latest, err := latestPackages(track)
if err != nil {
return "", err
}
if latest.Version == "" {
return "", fmt.Errorf("no latest version found for %q track", track)
}
return latest.Version, nil
}
type trackPackages struct {
Version string
Tarballs map[string]string
Exes []string
MSIs map[string]string
MacZips map[string]string
SPKs map[string]map[string]string
}
func latestPackages(track string) (*trackPackages, error) {
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/?mode=json&os=%s", track, runtime.GOOS)
res, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
}
defer res.Body.Close()
var latest trackPackages
if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
}
return &latest, nil
}
func requireRoot() error {
if os.Geteuid() == 0 {
return nil
}
switch runtime.GOOS {
case "linux":
return errors.New("must be root; use sudo")
case "freebsd", "openbsd":
return errors.New("must be root; use doas")
default:
return errors.New("must be root")
}
}

View File

@@ -0,0 +1,486 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package clientupdate
import (
"fmt"
"os"
"path/filepath"
"testing"
"tailscale.com/tailcfg"
)
func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
tests := []struct {
name string
toTrack string
in string
want string // empty means want no change
wantErr string
}{
{
name: "stable-to-unstable",
toTrack: UnstableTrack,
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
want: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "stable-unchanged",
toTrack: StableTrack,
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
},
{
name: "if-both-stable-and-unstable-dont-change",
toTrack: StableTrack,
in: "# Tailscale packages for debian buster\n" +
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "if-both-stable-and-unstable-dont-change-unstable",
toTrack: UnstableTrack,
in: "# Tailscale packages for debian buster\n" +
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "signed-by-form",
toTrack: UnstableTrack,
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n",
want: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n",
},
{
name: "unsupported-lines",
toTrack: UnstableTrack,
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n",
wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newContent, err := updateDebianAptSourcesListBytes([]byte(tt.in), tt.toTrack)
if err != nil {
if err.Error() != tt.wantErr {
t.Fatalf("error = %v; want %q", err, tt.wantErr)
}
return
}
if tt.wantErr != "" {
t.Fatalf("got no error; want %q", tt.wantErr)
}
var gotChange string
if string(newContent) != tt.in {
gotChange = string(newContent)
}
if gotChange != tt.want {
t.Errorf("wrong result\n got: %q\nwant: %q", gotChange, tt.want)
}
})
}
}
func TestParseSoftwareupdateList(t *testing.T) {
tests := []struct {
name string
input []byte
want string
}{
{
name: "update-at-end-of-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
* Label: Tailscale-1.23.4
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
`),
want: "Tailscale-1.23.4",
},
{
name: "update-in-middle-of-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: Tailscale-1.23.5000
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
`),
want: "Tailscale-1.23.5000",
},
{
name: "update-not-in-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
`),
want: "",
},
{
name: "decoy-in-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: Malware-1.0
Title: * Label: Tailscale-0.99.0, Version: 1.0, Size: 968K, Recommended: NOT REALLY TBH,
`),
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := parseSoftwareupdateList(test.input)
if test.want != got {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
func TestParsePacmanVersion(t *testing.T) {
tests := []struct {
desc string
out string
want string
wantErr bool
}{
{
desc: "valid version",
out: `
:: Synchronizing package databases...
endeavouros is up to date
core is up to date
extra is up to date
multilib is up to date
Repository : extra
Name : tailscale
Version : 1.44.2-1
Description : A mesh VPN that makes it easy to connect your devices, wherever they are.
Architecture : x86_64
URL : https://tailscale.com
Licenses : MIT
Groups : None
Provides : None
Depends On : glibc
Optional Deps : None
Conflicts With : None
Replaces : None
Download Size : 7.98 MiB
Installed Size : 32.47 MiB
Packager : Christian Heusel <gromit@archlinux.org>
Build Date : Tue 18 Jul 2023 12:28:37 PM PDT
Validated By : MD5 Sum SHA-256 Sum Signature
`,
want: "1.44.2",
},
{
desc: "version without Arch patch number",
out: `
... snip ...
Name : tailscale
Version : 1.44.2
Description : A mesh VPN that makes it easy to connect your devices, wherever they are.
... snip ...
`,
want: "1.44.2",
},
{
desc: "missing version",
out: `
... snip ...
Name : tailscale
Description : A mesh VPN that makes it easy to connect your devices, wherever they are.
... snip ...
`,
wantErr: true,
},
{
desc: "empty version",
out: `
... snip ...
Name : tailscale
Version :
Description : A mesh VPN that makes it easy to connect your devices, wherever they are.
... snip ...
`,
wantErr: true,
},
{
desc: "empty input",
out: "",
wantErr: true,
},
{
desc: "sneaky version in description",
out: `
... snip ...
Name : tailscale
Description : A mesh VPN that makes it easy to connect your devices, wherever they are. Version : 1.2.3
Version : 1.44.2
... snip ...
`,
want: "1.44.2",
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got, err := parsePacmanVersion([]byte(tt.out))
if err == nil && tt.wantErr {
t.Fatalf("got nil error and version %q, want non-nil error", got)
}
if err != nil && !tt.wantErr {
t.Fatalf("got error: %q, want nil", err)
}
if got != tt.want {
t.Fatalf("got version: %q, want %q", got, tt.want)
}
})
}
}
func TestUpdateYUMRepoTrack(t *testing.T) {
tests := []struct {
desc string
before string
track string
after string
rewrote bool
wantErr bool
}{
{
desc: "same track",
before: `
[tailscale-stable]
name=Tailscale stable
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
enabled=1
type=rpm
repo_gpgcheck=1
gpgcheck=0
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
`,
track: StableTrack,
after: `
[tailscale-stable]
name=Tailscale stable
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
enabled=1
type=rpm
repo_gpgcheck=1
gpgcheck=0
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
`,
},
{
desc: "change track",
before: `
[tailscale-stable]
name=Tailscale stable
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
enabled=1
type=rpm
repo_gpgcheck=1
gpgcheck=0
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
`,
track: UnstableTrack,
after: `
[tailscale-unstable]
name=Tailscale unstable
baseurl=https://pkgs.tailscale.com/unstable/fedora/$basearch
enabled=1
type=rpm
repo_gpgcheck=1
gpgcheck=0
gpgkey=https://pkgs.tailscale.com/unstable/fedora/repo.gpg
`,
rewrote: true,
},
{
desc: "non-tailscale repo file",
before: `
[fedora]
name=Fedora $releasever - $basearch
#baseurl=http://download.example/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/
metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=$basearch
enabled=1
countme=1
metadata_expire=7d
repo_gpgcheck=0
type=rpm
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch
skip_if_unavailable=False
`,
track: StableTrack,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "tailscale.repo")
if err := os.WriteFile(path, []byte(tt.before), 0644); err != nil {
t.Fatal(err)
}
rewrote, err := updateYUMRepoTrack(path, tt.track)
if err == nil && tt.wantErr {
t.Fatal("got nil error, want non-nil")
}
if err != nil && !tt.wantErr {
t.Fatalf("got error %q, want nil", err)
}
if err != nil {
return
}
if rewrote != tt.rewrote {
t.Errorf("got rewrote flag %v, want %v", rewrote, tt.rewrote)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(after) != tt.after {
t.Errorf("got repo file after update:\n%swant:\n%s", after, tt.after)
}
})
}
}
func TestParseAlpinePackageVersion(t *testing.T) {
tests := []struct {
desc string
out string
want string
wantErr bool
}{
{
desc: "valid version",
out: `
tailscale-1.44.2-r0 description:
The easiest, most secure way to use WireGuard and 2FA
tailscale-1.44.2-r0 webpage:
https://tailscale.com/
tailscale-1.44.2-r0 installed size:
32 MiB
`,
want: "1.44.2",
},
{
desc: "wrong package output",
out: `
busybox-1.36.1-r0 description:
Size optimized toolbox of many common UNIX utilities
busybox-1.36.1-r0 webpage:
https://busybox.net/
busybox-1.36.1-r0 installed size:
924 KiB
`,
wantErr: true,
},
{
desc: "missing version",
out: `
tailscale description:
The easiest, most secure way to use WireGuard and 2FA
tailscale webpage:
https://tailscale.com/
tailscale installed size:
32 MiB
`,
wantErr: true,
},
{
desc: "empty output",
out: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got, err := parseAlpinePackageVersion([]byte(tt.out))
if err == nil && tt.wantErr {
t.Fatalf("got nil error and version %q, want non-nil error", got)
}
if err != nil && !tt.wantErr {
t.Fatalf("got error: %q, want nil", err)
}
if got != tt.want {
t.Fatalf("got version: %q, want %q", got, tt.want)
}
})
}
}
func TestSynoArch(t *testing.T) {
tests := []struct {
goarch string
model string
want string
wantErr bool
}{
{goarch: "amd64", model: "DS224+", want: "x86_64"},
{goarch: "arm64", model: "DS124", want: "armv8"},
{goarch: "386", model: "DS415play", want: "i686"},
{goarch: "arm", model: "DS213air", want: "88f6281"},
{goarch: "arm", model: "NVR1218", want: "hi3535"},
{goarch: "arm", model: "DS1517", want: "alpine"},
{goarch: "arm", model: "DS216se", want: "armada370"},
{goarch: "arm", model: "DS115", want: "armada375"},
{goarch: "arm", model: "DS419slim", want: "armada38x"},
{goarch: "arm", model: "RS815", want: "armadaxp"},
{goarch: "arm", model: "DS414j", want: "comcerto2k"},
{goarch: "arm", model: "DS216play", want: "monaco"},
{goarch: "riscv64", model: "DS999", wantErr: true},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s-%s", tt.goarch, tt.model), func(t *testing.T) {
got, err := synoArch(&tailcfg.Hostinfo{GoArch: tt.goarch, DeviceModel: tt.model})
if err != nil {
if !tt.wantErr {
t.Fatalf("got unexpected error %v", err)
}
return
}
if tt.wantErr {
t.Fatalf("got %q, expected an error", got)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -1,20 +1,28 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Windows-specific stuff that can't go in update.go because it needs
// Windows-specific stuff that can't go in clientupdate.go because it needs
// x/sys/windows.
package cli
package clientupdate
import (
"golang.org/x/sys/windows"
"tailscale.com/util/winutil/authenticode"
)
func init() {
markTempFileFunc = markTempFileWindows
verifyAuthenticode = verifyTailscale
}
func markTempFileWindows(name string) error {
name16 := windows.StringToUTF16Ptr(name)
return windows.MoveFileEx(name16, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
}
const certSubjectTailscale = "Tailscale Inc."
func verifyTailscale(path string) error {
return authenticode.Verify(path, certSubjectTailscale)
}

View File

@@ -131,6 +131,8 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
} else {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
}
} else if ft.Elem().String() == "encoding/json.RawMessage" {
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
} else {
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
}

View File

@@ -12,9 +12,16 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/golang/protobuf/proto from github.com/matttproud/golang_protobuf_extensions/pbutil+
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
@@ -23,6 +30,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
github.com/matttproud/golang_protobuf_extensions/pbutil from github.com/prometheus/common/expfmt
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
@@ -34,9 +42,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
LD github.com/prometheus/procfs from github.com/prometheus/client_golang/prometheus
LD github.com/prometheus/procfs/internal/fs from github.com/prometheus/procfs
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/wgengine/filter
go4.org/netipx from tailscale.com/wgengine/filter+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
google.golang.org/protobuf/encoding/prototext from github.com/golang/protobuf/proto+
google.golang.org/protobuf/encoding/protowire from github.com/golang/protobuf/proto+
@@ -93,6 +104,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/net/packet from tailscale.com/wgengine/filter
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
tailscale.com/net/stun from tailscale.com/cmd/derper
L tailscale.com/net/tcpinfo from tailscale.com/derp
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
tailscale.com/net/tsaddr from tailscale.com/ipn+
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
@@ -103,7 +115,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime from tailscale.com/derp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
tailscale.com/tsweb from tailscale.com/cmd/derper
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
@@ -125,12 +138,14 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/cmpx from tailscale.com/cmd/derper+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
tailscale.com/util/mak from tailscale.com/syncs+
tailscale.com/util/multierr from tailscale.com/health
tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/set from tailscale.com/health+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
@@ -154,6 +169,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/exp/constraints from golang.org/x/exp/slices
golang.org/x/exp/maps from tailscale.com/types/views
golang.org/x/exp/slices from tailscale.com/net/tsaddr+
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
@@ -225,6 +241,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
io/fs from crypto/x509+
io/ioutil from github.com/mitchellh/go-ps+
log from expvar+
log/internal from log
math from compress/flate+
math/big from crypto/dsa+
math/bits from compress/flate+

View File

@@ -33,6 +33,7 @@ import (
"tailscale.com/net/stun"
"tailscale.com/tsweb"
"tailscale.com/types/key"
"tailscale.com/util/cmpx"
)
var (
@@ -181,8 +182,9 @@ func main() {
}
mux.HandleFunc("/derp/probe", probeHandler)
go refreshBootstrapDNSLoop()
mux.HandleFunc("/bootstrap-dns", handleBootstrapDNS)
mux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
io.WriteString(w, `<html><body>
@@ -202,6 +204,7 @@ func main() {
}
}))
mux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
io.WriteString(w, "User-agent: *\nDisallow: /\n")
}))
mux.Handle("/generate_204", http.HandlerFunc(serveNoContent))
@@ -276,18 +279,6 @@ func main() {
defer tlsActiveVersion.Add(label, -1)
}
// Set HTTP headers to appease automated security scanners.
//
// Security automation gets cranky when HTTPS sites don't
// set HSTS, and when they don't specify a content
// security policy for XSS mitigation.
//
// DERP's HTTP interface is only ever used for debug
// access (for which trivial safe policies work just
// fine), and by DERP clients which don't obey any of
// these browser-centric headers anyway.
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'self'; block-all-mixed-content; plugin-types 'none'")
mux.ServeHTTP(w, r)
})
if *httpPort > -1 {
@@ -436,11 +427,7 @@ func defaultMeshPSKFile() string {
}
func rateLimitedListenAndServeTLS(srv *http.Server) error {
addr := srv.Addr
if addr == "" {
addr = ":https"
}
ln, err := net.Listen("tcp", addr)
ln, err := net.Listen("tcp", cmpx.Or(srv.Addr, ":https"))
if err != nil {
return err
}

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"log"
"net"
"net/netip"
"strings"
"time"
@@ -67,7 +68,7 @@ func startMeshWithHost(s *derp.Server, host string) error {
return d.DialContext(ctx, network, addr)
})
add := func(k key.NodePublic) { s.AddPacketForwarder(k, c) }
add := func(k key.NodePublic, _ netip.AddrPort) { s.AddPacketForwarder(k, c) }
remove := func(k key.NodePublic) { s.RemovePacketForwarder(k, c) }
go c.RunWatchConnectionLoop(context.Background(), s.PublicKey(), logf, add, remove)
return nil

4
cmd/dist/dist.go vendored
View File

@@ -19,10 +19,10 @@ import (
var synologyPackageCenter bool
func getTargets() ([]dist.Target, error) {
func getTargets(signers unixpkgs.Signers) ([]dist.Target, error) {
var ret []dist.Target
ret = append(ret, unixpkgs.Targets()...)
ret = append(ret, unixpkgs.Targets(signers)...)
// Synology packages can be built either for sideloading, or for
// distribution by Synology in their package center. When
// distributed through the package center, apps can request

View File

@@ -16,6 +16,7 @@ import (
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/util/cmpx"
)
func main() {
@@ -39,10 +40,7 @@ func main() {
log.Fatal("at least one tag must be specified")
}
baseURL := os.Getenv("TS_BASE_URL")
if baseURL == "" {
baseURL = "https://api.tailscale.com"
}
baseURL := cmpx.Or(os.Getenv("TS_BASE_URL"), "https://api.tailscale.com")
credentials := clientcredentials.Config{
ClientID: clientID,

View File

@@ -23,6 +23,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"github.com/tailscale/hujson"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/util/httpm"
)
@@ -270,7 +271,7 @@ func applyNewACL(ctx context.Context, client *http.Client, tailnet, apiKey, poli
got := resp.StatusCode
want := http.StatusOK
if got != want {
var ate ACLTestError
var ate ACLGitopsTestError
err := json.NewDecoder(resp.Body).Decode(&ate)
if err != nil {
return err
@@ -306,7 +307,7 @@ func testNewACLs(ctx context.Context, client *http.Client, tailnet, apiKey, poli
}
defer resp.Body.Close()
var ate ACLTestError
var ate ACLGitopsTestError
err = json.NewDecoder(resp.Body).Decode(&ate)
if err != nil {
return err
@@ -327,12 +328,12 @@ func testNewACLs(ctx context.Context, client *http.Client, tailnet, apiKey, poli
var lineColMessageSplit = regexp.MustCompile(`line ([0-9]+), column ([0-9]+): (.*)$`)
type ACLTestError struct {
Message string `json:"message"`
Data []ACLTestErrorDetail `json:"data"`
// ACLGitopsTestError is redefined here so we can add a custom .Error() response
type ACLGitopsTestError struct {
tailscale.ACLTestError
}
func (ate ACLTestError) Error() string {
func (ate ACLGitopsTestError) Error() string {
var sb strings.Builder
if *githubSyntax && lineColMessageSplit.MatchString(ate.Message) {
@@ -349,20 +350,28 @@ func (ate ACLTestError) Error() string {
fmt.Fprintln(&sb)
for _, data := range ate.Data {
fmt.Fprintf(&sb, "For user %s:\n", data.User)
for _, err := range data.Errors {
fmt.Fprintf(&sb, "- %s\n", err)
if data.User != "" {
fmt.Fprintf(&sb, "For user %s:\n", data.User)
}
if len(data.Errors) > 0 {
fmt.Fprint(&sb, "Errors found:\n")
for _, err := range data.Errors {
fmt.Fprintf(&sb, "- %s\n", err)
}
}
if len(data.Warnings) > 0 {
fmt.Fprint(&sb, "Warnings found:\n")
for _, err := range data.Warnings {
fmt.Fprintf(&sb, "- %s\n", err)
}
}
}
return sb.String()
}
type ACLTestErrorDetail struct {
User string `json:"user"`
Errors []string `json:"errors"`
}
func getACLETag(ctx context.Context, client *http.Client, tailnet, apiKey string) (string, error) {
req, err := http.NewRequestWithContext(ctx, httpm.GET, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), nil)
if err != nil {

View File

@@ -0,0 +1,55 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"encoding/json"
"strings"
"testing"
"tailscale.com/client/tailscale"
)
func TestEmbeddedTypeUnmarshal(t *testing.T) {
var gitopsErr ACLGitopsTestError
gitopsErr.Message = "gitops response error"
gitopsErr.Data = []tailscale.ACLTestFailureSummary{
{
User: "GitopsError",
Errors: []string{"this was initially created as a gitops error"},
},
}
var aclTestErr tailscale.ACLTestError
aclTestErr.Message = "native ACL response error"
aclTestErr.Data = []tailscale.ACLTestFailureSummary{
{
User: "ACLError",
Errors: []string{"this was initially created as an ACL error"},
},
}
t.Run("unmarshal gitops type from acl type", func(t *testing.T) {
b, _ := json.Marshal(aclTestErr)
var e ACLGitopsTestError
err := json.Unmarshal(b, &e)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(e.Error(), "For user ACLError") { // the gitops error prints out the user, the acl error doesn't
t.Fatalf("user heading for 'ACLError' not found in gitops error: %v", e.Error())
}
})
t.Run("unmarshal acl type from gitops type", func(t *testing.T) {
b, _ := json.Marshal(gitopsErr)
var e tailscale.ACLTestError
err := json.Unmarshal(b, &e)
if err != nil {
t.Fatal(err)
}
expectedErr := `Status: 0, Message: "gitops response error", Data: [{User:GitopsError Errors:[this was initially created as a gitops error] Warnings:[]}]`
if e.Error() != expectedErr {
t.Fatalf("got %v\n, expected %v", e.Error(), expectedErr)
}
})
}

View File

@@ -11,35 +11,40 @@ import (
"os"
"strings"
"github.com/goreleaser/nfpm"
_ "github.com/goreleaser/nfpm/deb"
_ "github.com/goreleaser/nfpm/rpm"
"github.com/goreleaser/nfpm/v2"
_ "github.com/goreleaser/nfpm/v2/deb"
"github.com/goreleaser/nfpm/v2/files"
_ "github.com/goreleaser/nfpm/v2/rpm"
)
// parseFiles parses a comma-separated list of colon-separated pairs
// into a map of filePathOnDisk -> filePathInPackage.
func parseFiles(s string) (map[string]string, error) {
ret := map[string]string{}
// into files.Contents format.
func parseFiles(s string, typ string) (files.Contents, error) {
if len(s) == 0 {
return ret, nil
return nil, nil
}
var contents files.Contents
for _, f := range strings.Split(s, ",") {
fs := strings.Split(f, ":")
if len(fs) != 2 {
return nil, fmt.Errorf("unparseable file field %q", f)
}
ret[fs[0]] = fs[1]
contents = append(contents, &files.Content{Type: files.TypeFile, Source: fs[0], Destination: fs[1]})
}
return ret, nil
return contents, nil
}
func parseEmptyDirs(s string) []string {
func parseEmptyDirs(s string) files.Contents {
// strings.Split("", ",") would return []string{""}, which is not suitable:
// this would create an empty dir record with path "", breaking the package
if s == "" {
return nil
}
return strings.Split(s, ",")
var contents files.Contents
for _, d := range strings.Split(s, ",") {
contents = append(contents, &files.Content{Type: files.TypeDir, Destination: d})
}
return contents
}
func main() {
@@ -48,7 +53,7 @@ func main() {
description := flag.String("description", "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO", "package description")
goarch := flag.String("arch", "amd64", "GOARCH this package is for")
pkgType := flag.String("type", "deb", "type of package to build (deb or rpm)")
files := flag.String("files", "", "comma-separated list of files in src:dst form")
regularFiles := flag.String("files", "", "comma-separated list of files in src:dst form")
configFiles := flag.String("configs", "", "like --files, but for files marked as user-editable config files")
emptyDirs := flag.String("emptydirs", "", "comma-separated list of empty directories")
version := flag.String("version", "0.0.0", "version of the package")
@@ -60,15 +65,20 @@ func main() {
recommends := flag.String("recommends", "", "comma-separated list of packages this package recommends")
flag.Parse()
filesMap, err := parseFiles(*files)
filesList, err := parseFiles(*regularFiles, files.TypeFile)
if err != nil {
log.Fatalf("Parsing --files: %v", err)
}
configsMap, err := parseFiles(*configFiles)
configsList, err := parseFiles(*configFiles, files.TypeConfig)
if err != nil {
log.Fatalf("Parsing --configs: %v", err)
}
emptyDirList := parseEmptyDirs(*emptyDirs)
contents := append(filesList, append(configsList, emptyDirList...)...)
contents, err = files.PrepareForPackager(contents, 0, *pkgType, false)
if err != nil {
log.Fatalf("Building package contents: %v", err)
}
info := nfpm.WithDefaults(&nfpm.Info{
Name: *name,
Arch: *goarch,
@@ -79,9 +89,7 @@ func main() {
Homepage: "https://www.tailscale.com",
License: "MIT",
Overridables: nfpm.Overridables{
EmptyFolders: emptyDirList,
Files: filesMap,
ConfigFiles: configsMap,
Contents: contents,
Scripts: nfpm.Scripts{
PostInstall: *postinst,
PreRemove: *prerm,

View File

@@ -45,6 +45,7 @@ import (
"golang.org/x/exp/slices"
"tailscale.com/types/logid"
"tailscale.com/types/netlogtype"
"tailscale.com/util/cmpx"
"tailscale.com/util/must"
)
@@ -151,10 +152,10 @@ func printMessage(msg message) {
if len(traffic) == 0 {
return
}
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) bool {
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) int {
nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes
ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes
return nx > ny
return cmpx.Compare(ny, nx)
})
var sum netlogtype.Counts
for _, cc := range traffic {

View File

@@ -22,15 +22,25 @@ import (
"tailscale.com/net/netutil"
"tailscale.com/tsnet"
"tailscale.com/types/nettype"
"tailscale.com/util/clientmetric"
)
var (
ports = flag.String("ports", "443", "comma-separated list of ports to proxy")
wgPort = flag.Int("wg-listen-port", 0, "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
promoteHTTPS = flag.Bool("promote-https", true, "promote HTTP to HTTPS")
)
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
var (
numSessions = clientmetric.NewCounter("sniproxy_sessions")
numBadAddrPort = clientmetric.NewCounter("sniproxy_bad_addrport")
dnsResponses = clientmetric.NewCounter("sniproxy_dns_responses")
dnsFailures = clientmetric.NewCounter("sniproxy_dns_failed")
httpPromoted = clientmetric.NewCounter("sniproxy_http_promoted")
)
func main() {
flag.Parse()
if *ports == "" {
@@ -40,6 +50,7 @@ func main() {
hostinfo.SetApp("sniproxy")
var s server
s.ts.Port = uint16(*wgPort)
defer s.ts.Close()
lc, err := s.ts.LocalClient()
@@ -107,6 +118,7 @@ func (s *server) serveDNSConn(c nettype.ConnPacketConn) {
n, err := c.Read(buf)
if err != nil {
log.Printf("c.Read failed: %v\n ", err)
dnsFailures.Add(1)
return
}
@@ -114,20 +126,25 @@ func (s *server) serveDNSConn(c nettype.ConnPacketConn) {
err = msg.Unpack(buf[:n])
if err != nil {
log.Printf("dnsmessage unpack failed: %v\n ", err)
dnsFailures.Add(1)
return
}
buf, err = s.dnsResponse(&msg)
if err != nil {
log.Printf("s.dnsResponse failed: %v\n", err)
dnsFailures.Add(1)
return
}
_, err = c.Write(buf)
if err != nil {
log.Printf("c.Write failed: %v\n", err)
dnsFailures.Add(1)
return
}
dnsResponses.Add(1)
}
func (s *server) serveConn(c net.Conn) {
@@ -135,6 +152,7 @@ func (s *server) serveConn(c net.Conn) {
_, port, err := net.SplitHostPort(addrPortStr)
if err != nil {
log.Printf("bogus addrPort %q", addrPortStr)
numBadAddrPort.Add(1)
c.Close()
return
}
@@ -147,6 +165,7 @@ func (s *server) serveConn(c net.Conn) {
return netutil.NewOneConnListener(c, nil), nil
}
p.AddSNIRouteFunc(addrPortStr, func(ctx context.Context, sniName string) (t tcpproxy.Target, ok bool) {
numSessions.Add(1)
return &tcpproxy.DialProxy{
Addr: net.JoinHostPort(sniName, port),
DialContext: dialer.DialContext,
@@ -216,6 +235,7 @@ func (s *server) dnsResponse(req *dnsmessage.Message) (buf []byte, err error) {
func (s *server) promoteHTTPS(ln net.Listener) {
err := http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpPromoted.Add(1)
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusFound)
}))
log.Fatalf("promoteHTTPS http.Serve: %v", err)

View File

@@ -1,38 +0,0 @@
/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2019-2022 WireGuard LLC. All Rights Reserved.
*/
package cli
import (
"unsafe"
"golang.org/x/sys/windows"
)
func init() {
verifyAuthenticode = verifyAuthenticodeWindows
}
func verifyAuthenticodeWindows(path string) error {
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
data := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_WHOLECHAIN, // Full revocation checking, as this is called with network connectivity.
UnionChoice: windows.WTD_CHOICE_FILE,
StateAction: windows.WTD_STATEACTION_VERIFY,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: path16,
}),
}
err = windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
data.StateAction = windows.WTD_STATEACTION_CLOSE
windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
return err
}

View File

@@ -129,16 +129,12 @@ change in the future.
certCmd,
netlockCmd,
licensesCmd,
exitNodeCmd,
},
FlagSet: rootfs,
Exec: func(context.Context, []string) error { return flag.ErrHelp },
UsageFunc: usageFunc,
}
for _, c := range rootCmd.Subcommands {
if c.UsageFunc == nil {
c.UsageFunc = usageFunc
}
}
if envknob.UseWIPCode() {
rootCmd.Subcommands = append(rootCmd.Subcommands,
idTokenCmd,
@@ -156,6 +152,12 @@ change in the future.
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
}
for _, c := range rootCmd.Subcommands {
if c.UsageFunc == nil {
c.UsageFunc = usageFunc
}
}
if err := rootCmd.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil

View File

@@ -18,10 +18,12 @@ import (
"tailscale.com/health/healthmsg"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/tstest"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
"tailscale.com/util/cmpx"
"tailscale.com/version/distro"
)
@@ -719,10 +721,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var warnBuf tstest.MemLogger
goos := tt.goos
if goos == "" {
goos = "linux"
}
goos := cmpx.Or(tt.goos, "linux")
st := tt.st
if st == nil {
st = new(ipnstate.Status)
@@ -836,7 +835,7 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
},
env: upCheckEnv{
backendState: "Stopped",
@@ -848,7 +847,7 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
},
env: upCheckEnv{backendState: "Running"},
wantSimpleUp: true,
@@ -859,7 +858,7 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--reset"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
},
env: upCheckEnv{backendState: "Running"},
wantJustEditMP: &ipn.MaskedPrefs{
@@ -886,7 +885,7 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
},
env: upCheckEnv{backendState: "Running"},
wantSimpleUp: true,
@@ -897,7 +896,7 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--login-server=https://localhost:1000"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
@@ -912,7 +911,7 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--advertise-tags=tag:foo"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
@@ -946,7 +945,7 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--ssh"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
@@ -967,7 +966,7 @@ func TestUpdatePrefs(t *testing.T) {
flags: []string{"--ssh=false"},
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
RunSSH: true,
@@ -992,7 +991,7 @@ func TestUpdatePrefs(t *testing.T) {
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
@@ -1016,7 +1015,7 @@ func TestUpdatePrefs(t *testing.T) {
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
@@ -1039,7 +1038,7 @@ func TestUpdatePrefs(t *testing.T) {
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
@@ -1061,7 +1060,7 @@ func TestUpdatePrefs(t *testing.T) {
sshOverTailscale: true,
curPrefs: &ipn.Prefs{
ControlURL: "https://login.tailscale.com",
Persist: &persist.Persist{LoginName: "crawshaw.github"},
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
AllowSingleHosts: true,
CorpDNS: true,
RunSSH: true,

View File

@@ -127,6 +127,16 @@ var debugCmd = &ffcli.Command{
Exec: localAPIAction("rebind"),
ShortHelp: "force a magicsock rebind",
},
{
Name: "break-tcp-conns",
Exec: localAPIAction("break-tcp-conns"),
ShortHelp: "break any open TCP connections from the daemon",
},
{
Name: "break-derp-conns",
Exec: localAPIAction("break-derp-conns"),
ShortHelp: "break any open DERP connections from the daemon",
},
{
Name: "prefs",
Exec: runPrefs,

View File

@@ -0,0 +1,248 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/cmpx"
)
var exitNodeCmd = &ffcli.Command{
Name: "exit-node",
ShortUsage: "exit-node [flags]",
Subcommands: []*ffcli.Command{
{
Name: "list",
ShortUsage: "exit-node list [flags]",
ShortHelp: "Show exit nodes",
Exec: runExitNodeList,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("list")
fs.StringVar(&exitNodeArgs.filter, "filter", "", "filter exit nodes by country")
return fs
})(),
},
},
Exec: func(context.Context, []string) error {
return errors.New("exit-node subcommand required; run 'tailscale exit-node -h' for details")
},
}
var exitNodeArgs struct {
filter string
}
// runExitNodeList returns a formatted list of exit nodes for a tailnet.
// If the exit node has location and priority data, only the highest
// priority node for each city location is shown to the user.
// If the country location has more than one city, an 'Any' city
// is returned for the country, which lists the highest priority
// node in that country.
// For countries without location data, each exit node is displayed.
func runExitNodeList(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("unexpected non-flag arguments to 'tailscale exit-node list'")
}
getStatus := localClient.Status
st, err := getStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
var peers []*ipnstate.PeerStatus
for _, ps := range st.Peer {
if !ps.ExitNodeOption {
// We only show exit nodes under the exit-node subcommand.
continue
}
peers = append(peers, ps)
}
if len(peers) == 0 {
return errors.New("no exit nodes found")
}
filteredPeers := filterFormatAndSortExitNodes(peers, exitNodeArgs.filter)
if len(filteredPeers.Countries) == 0 && exitNodeArgs.filter != "" {
return fmt.Errorf("no exit nodes found for %q", exitNodeArgs.filter)
}
w := tabwriter.NewWriter(os.Stdout, 10, 5, 5, ' ', 0)
defer w.Flush()
fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t", "IP", "HOSTNAME", "COUNTRY", "CITY", "STATUS")
for _, country := range filteredPeers.Countries {
for _, city := range country.Cities {
for _, peer := range city.Peers {
fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t", peer.TailscaleIPs[0], strings.Trim(peer.DNSName, "."), country.Name, city.Name, peerStatus(peer))
}
}
}
fmt.Fprintln(w)
fmt.Fprintln(w)
fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP")
return nil
}
// peerStatus returns a string representing the current state of
// a peer. If there is no notable state, a - is returned.
func peerStatus(peer *ipnstate.PeerStatus) string {
if !peer.Active {
if peer.ExitNode {
return "selected but offline"
}
if !peer.Online {
return "offline"
}
}
if peer.ExitNode {
return "selected"
}
return "-"
}
type filteredExitNodes struct {
Countries []*filteredCountry
}
type filteredCountry struct {
Name string
Cities []*filteredCity
}
type filteredCity struct {
Name string
Peers []*ipnstate.PeerStatus
}
const noLocationData = "-"
// filterFormatAndSortExitNodes filters and sorts exit nodes into
// alphabetical order, by country, city and then by priority if
// present.
// If an exit node has location data, and the country has more than
// once city, an `Any` city is added to the country that contains the
// highest priority exit node within that country.
// For exit nodes without location data, their country fields are
// defined as '-' to indicate that the data is not available.
func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string) filteredExitNodes {
countries := make(map[string]*filteredCountry)
cities := make(map[string]*filteredCity)
for _, ps := range peers {
if ps.Location == nil {
ps.Location = &tailcfg.Location{
Country: noLocationData,
CountryCode: noLocationData,
City: noLocationData,
CityCode: noLocationData,
}
}
if filterBy != "" && ps.Location.Country != filterBy {
continue
}
co, coOK := countries[ps.Location.CountryCode]
if !coOK {
co = &filteredCountry{
Name: ps.Location.Country,
}
countries[ps.Location.CountryCode] = co
}
ci, ciOK := cities[ps.Location.CityCode]
if !ciOK {
ci = &filteredCity{
Name: ps.Location.City,
}
cities[ps.Location.CityCode] = ci
co.Cities = append(co.Cities, ci)
}
ci.Peers = append(ci.Peers, ps)
}
filteredExitNodes := filteredExitNodes{
Countries: maps.Values(countries),
}
for _, country := range filteredExitNodes.Countries {
if country.Name == noLocationData {
// Countries without location data should not
// be filtered further.
continue
}
var countryANYPeer []*ipnstate.PeerStatus
for _, city := range country.Cities {
sortPeersByPriority(city.Peers)
countryANYPeer = append(countryANYPeer, city.Peers...)
var reducedCityPeers []*ipnstate.PeerStatus
for i, peer := range city.Peers {
if i == 0 || peer.ExitNode {
// We only return the highest priority peer and any peer that
// is currently the active exit node.
reducedCityPeers = append(reducedCityPeers, peer)
}
}
city.Peers = reducedCityPeers
}
sortByCityName(country.Cities)
sortPeersByPriority(countryANYPeer)
if len(country.Cities) > 1 {
// For countries with more than one city, we want to return the
// option of the best peer for that country.
country.Cities = append([]*filteredCity{
{
Name: "Any",
Peers: []*ipnstate.PeerStatus{countryANYPeer[0]},
},
}, country.Cities...)
}
}
sortByCountryName(filteredExitNodes.Countries)
return filteredExitNodes
}
// sortPeersByPriority sorts a slice of PeerStatus
// by location.Priority, in order of highest priority.
func sortPeersByPriority(peers []*ipnstate.PeerStatus) {
slices.SortStableFunc(peers, func(a, b *ipnstate.PeerStatus) int {
return cmpx.Compare(b.Location.Priority, a.Location.Priority)
})
}
// sortByCityName sorts a slice of filteredCity alphabetically
// by name. The '-' used to indicate no location data will always
// be sorted to the front of the slice.
func sortByCityName(cities []*filteredCity) {
slices.SortStableFunc(cities, func(a, b *filteredCity) int { return strings.Compare(a.Name, b.Name) })
}
// sortByCountryName sorts a slice of filteredCountry alphabetically
// by name. The '-' used to indicate no location data will always
// be sorted to the front of the slice.
func sortByCountryName(countries []*filteredCountry) {
slices.SortStableFunc(countries, func(a, b *filteredCountry) int { return strings.Compare(a.Name, b.Name) })
}

View File

@@ -0,0 +1,308 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func TestFilterFormatAndSortExitNodes(t *testing.T) {
t.Run("without filter", func(t *testing.T) {
ps := []*ipnstate.PeerStatus{
{
HostName: "everest-1",
Location: &tailcfg.Location{
Country: "Everest",
CountryCode: "evr",
City: "Hillary",
CityCode: "hil",
Priority: 100,
},
},
{
HostName: "lhotse-1",
Location: &tailcfg.Location{
Country: "Lhotse",
CountryCode: "lho",
City: "Fritz",
CityCode: "fri",
Priority: 200,
},
},
{
HostName: "lhotse-2",
Location: &tailcfg.Location{
Country: "Lhotse",
CountryCode: "lho",
City: "Fritz",
CityCode: "fri",
Priority: 100,
},
},
{
HostName: "nuptse-1",
Location: &tailcfg.Location{
Country: "Nuptse",
CountryCode: "nup",
City: "Walmsley",
CityCode: "wal",
Priority: 200,
},
},
{
HostName: "nuptse-2",
Location: &tailcfg.Location{
Country: "Nuptse",
CountryCode: "nup",
City: "Bonington",
CityCode: "bon",
Priority: 10,
},
},
{
HostName: "Makalu",
},
}
want := filteredExitNodes{
Countries: []*filteredCountry{
{
Name: noLocationData,
Cities: []*filteredCity{
{
Name: noLocationData,
Peers: []*ipnstate.PeerStatus{
ps[5],
},
},
},
},
{
Name: "Everest",
Cities: []*filteredCity{
{
Name: "Hillary",
Peers: []*ipnstate.PeerStatus{
ps[0],
},
},
},
},
{
Name: "Lhotse",
Cities: []*filteredCity{
{
Name: "Fritz",
Peers: []*ipnstate.PeerStatus{
ps[1],
},
},
},
},
{
Name: "Nuptse",
Cities: []*filteredCity{
{
Name: "Any",
Peers: []*ipnstate.PeerStatus{
ps[3],
},
},
{
Name: "Bonington",
Peers: []*ipnstate.PeerStatus{
ps[4],
},
},
{
Name: "Walmsley",
Peers: []*ipnstate.PeerStatus{
ps[3],
},
},
},
},
},
}
result := filterFormatAndSortExitNodes(ps, "")
if res := cmp.Diff(result.Countries, want.Countries, cmpopts.IgnoreUnexported(key.NodePublic{})); res != "" {
t.Fatalf(res)
}
})
t.Run("with country filter", func(t *testing.T) {
ps := []*ipnstate.PeerStatus{
{
HostName: "baker-1",
Location: &tailcfg.Location{
Country: "Pacific",
CountryCode: "pst",
City: "Baker",
CityCode: "col",
Priority: 100,
},
},
{
HostName: "hood-1",
Location: &tailcfg.Location{
Country: "Pacific",
CountryCode: "pst",
City: "Hood",
CityCode: "hoo",
Priority: 500,
},
},
{
HostName: "rainier-1",
Location: &tailcfg.Location{
Country: "Pacific",
CountryCode: "pst",
City: "Rainier",
CityCode: "rai",
Priority: 100,
},
},
{
HostName: "rainier-2",
Location: &tailcfg.Location{
Country: "Pacific",
CountryCode: "pst",
City: "Rainier",
CityCode: "rai",
Priority: 10,
},
},
{
HostName: "mitchell-1",
Location: &tailcfg.Location{
Country: "Atlantic",
CountryCode: "atl",
City: "Mitchell",
CityCode: "mit",
Priority: 200,
},
},
}
want := filteredExitNodes{
Countries: []*filteredCountry{
{
Name: "Pacific",
Cities: []*filteredCity{
{
Name: "Any",
Peers: []*ipnstate.PeerStatus{
ps[1],
},
},
{
Name: "Baker",
Peers: []*ipnstate.PeerStatus{
ps[0],
},
},
{
Name: "Hood",
Peers: []*ipnstate.PeerStatus{
ps[1],
},
},
{
Name: "Rainier",
Peers: []*ipnstate.PeerStatus{
ps[2],
},
},
},
},
},
}
result := filterFormatAndSortExitNodes(ps, "Pacific")
if res := cmp.Diff(result.Countries, want.Countries, cmpopts.IgnoreUnexported(key.NodePublic{})); res != "" {
t.Fatalf(res)
}
})
}
func TestSortPeersByPriority(t *testing.T) {
ps := []*ipnstate.PeerStatus{
{
Location: &tailcfg.Location{
Priority: 100,
},
},
{
Location: &tailcfg.Location{
Priority: 200,
},
},
{
Location: &tailcfg.Location{
Priority: 300,
},
},
}
sortPeersByPriority(ps)
if ps[0].Location.Priority != 300 {
t.Fatalf("sortPeersByPriority did not order PeerStatus with highest priority as index 0, got %v, want %v", ps[0].Location.Priority, 300)
}
}
func TestSortByCountryName(t *testing.T) {
fc := []*filteredCountry{
{
Name: "Albania",
},
{
Name: "Sweden",
},
{
Name: "Zimbabwe",
},
{
Name: noLocationData,
},
}
sortByCountryName(fc)
if fc[0].Name != noLocationData {
t.Fatalf("sortByCountryName did not order countries by alphabetical order, got %v, want %v", fc[0].Name, noLocationData)
}
}
func TestSortByCityName(t *testing.T) {
fc := []*filteredCity{
{
Name: "Kingston",
},
{
Name: "Goteborg",
},
{
Name: "Squamish",
},
{
Name: noLocationData,
},
}
sortByCityName(fc)
if fc[0].Name != noLocationData {
t.Fatalf("sortByCityName did not order cities by alphabetical order, got %v, want %v", fc[0].Name, noLocationData)
}
}

View File

@@ -13,7 +13,10 @@ import (
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/exp/slices"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
)
@@ -30,10 +33,10 @@ func newFunnelCommand(e *serveEnv) *ffcli.Command {
return &ffcli.Command{
Name: "funnel",
ShortHelp: "Turn on/off Funnel service",
ShortUsage: strings.TrimSpace(`
funnel <serve-port> {on|off}
funnel status [--json]
`),
ShortUsage: strings.Join([]string{
"funnel <serve-port> {on|off}",
"funnel status [--json]",
}, "\n "),
LongHelp: strings.Join([]string{
"Funnel allows you to publish a 'tailscale serve'",
"server publicly, open to the entire internet.",
@@ -91,9 +94,15 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
}
port := uint16(port64)
if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil {
return err
if on {
// Don't block from turning off existing Funnel if
// network configuration/capabilities have changed.
// Only block from starting new Funnels.
if err := e.verifyFunnelEnabled(ctx, st, port); err != nil {
return err
}
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port)))
if on == sc.AllowFunnel[hp] {
@@ -117,6 +126,49 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
return nil
}
// verifyFunnelEnabled verifies that the self node is allowed to use Funnel.
//
// If Funnel is not yet enabled by the current node capabilities,
// the user is sent through an interactive flow to enable the feature.
// Once enabled, verifyFunnelEnabled checks that the given port is allowed
// with Funnel.
//
// If an error is reported, the CLI should stop execution and return the error.
//
// verifyFunnelEnabled may refresh the local state and modify the st input.
func (e *serveEnv) verifyFunnelEnabled(ctx context.Context, st *ipnstate.Status, port uint16) error {
hasFunnelAttrs := func(attrs []string) bool {
hasHTTPS := slices.Contains(attrs, tailcfg.CapabilityHTTPS)
hasFunnel := slices.Contains(attrs, tailcfg.NodeAttrFunnel)
return hasHTTPS && hasFunnel
}
if hasFunnelAttrs(st.Self.Capabilities) {
return nil // already enabled
}
enableErr := e.enableFeatureInteractive(ctx, "funnel", hasFunnelAttrs)
st, statusErr := e.getLocalClientStatus(ctx) // get updated status; interactive flow may block
switch {
case statusErr != nil:
return fmt.Errorf("getting client status: %w", statusErr)
case enableErr != nil:
// enableFeatureInteractive is a new flow behind a control server
// feature flag. If anything caused it to error, fallback to using
// the old CheckFunnelAccess call. Likely this domain does not have
// the feature flag on.
// TODO(sonia,tailscale/corp#10577): Remove this fallback once the
// control flag is turned on for all domains.
if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil {
return err
}
default:
// Done with enablement, make sure the requested port is allowed.
if err := ipn.CheckFunnelPort(port, st.Self.Capabilities); err != nil {
return err
}
}
return nil
}
// printFunnelWarning prints a warning if the Funnel is on but there is no serve
// config for its host:port.
func printFunnelWarning(sc *ipn.ServeConfig) {
@@ -129,7 +181,7 @@ func printFunnelWarning(sc *ipn.ServeConfig) {
p, _ := strconv.ParseUint(portStr, 10, 16)
if _, ok := sc.TCP[uint16(p)]; !ok {
warn = true
fmt.Fprintf(os.Stderr, "Warning: funnel=on for %s, but no serve config\n", hp)
fmt.Fprintf(os.Stderr, "\nWarning: funnel=on for %s, but no serve config\n", hp)
}
}
if warn {

View File

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

View File

@@ -52,7 +52,6 @@ func runNetcheck(ctx context.Context, args []string) error {
return err
}
c := &netcheck.Client{
UDPBindAddr: envknob.String("TS_DEBUG_NETCHECK_UDP_BIND"),
PortMapper: portmapper.NewClient(logf, netMon, nil, nil),
UseDNSCache: false, // always resolve, don't cache
}
@@ -67,6 +66,10 @@ func runNetcheck(ctx context.Context, args []string) error {
fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface")
}
if err := c.Standalone(ctx, envknob.String("TS_DEBUG_NETCHECK_UDP_BIND")); err != nil {
fmt.Fprintln(Stderr, "netcheck: UDP test failure:", err)
}
dm, err := localClient.CurrentDERPMap(ctx)
noRegions := dm != nil && len(dm.Regions) == 0
if noRegions {

View File

@@ -23,6 +23,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
)
var netlockCmd = &ffcli.Command{
@@ -40,6 +41,7 @@ var netlockCmd = &ffcli.Command{
nlDisablementKDFCmd,
nlLogCmd,
nlLocalDisableCmd,
nlRevokeKeysCmd,
},
Exec: runNetworkLockNoSubcommand,
}
@@ -465,7 +467,16 @@ func runNetworkLockSign(ctx context.Context, args []string) error {
}
}
return localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier()))
err := localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier()))
// Provide a better help message for when someone clicks through the signing flow
// on the wrong device.
if err != nil && strings.Contains(err.Error(), "this node is not trusted by network lock") {
fmt.Fprintln(os.Stderr, "Error: Signing is not available on this device because it does not have a trusted tailnet lock key.")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Try again on a signing device instead. Tailnet admins can see signing devices on the admin panel.")
fmt.Fprintln(os.Stderr)
}
return err
}
var nlDisableCmd = &ffcli.Command{
@@ -702,3 +713,114 @@ func wrapAuthKey(ctx context.Context, keyStr string, status *ipnstate.Status) er
fmt.Println(wrapped)
return nil
}
var nlRevokeKeysArgs struct {
cosign bool
finish bool
forkFrom string
}
var nlRevokeKeysCmd = &ffcli.Command{
Name: "revoke-keys",
ShortUsage: "revoke-keys <tailnet-lock-key>...\n revoke-keys [--cosign] [--finish] <recovery-blob>",
ShortHelp: "Revoke compromised tailnet-lock keys",
LongHelp: `Retroactively revoke the specified tailnet lock keys (tlpub:abc).
Revoked keys are prevented from being used in the future. Any nodes previously signed
by revoked keys lose their authorization and must be signed again.
Revocation is a multi-step process that requires several signing nodes to ` + "`--cosign`" + ` the revocation. Use ` + "`tailscale lock remove`" + ` instead if the key has not been compromised.
1. To start, run ` + "`tailscale revoke-keys <tlpub-keys>`" + ` with the tailnet lock keys to revoke.
2. Re-run the ` + "`--cosign`" + ` command output by ` + "`revoke-keys`" + ` on other signing nodes. Use the
most recent command output on the next signing node in sequence.
3. Once the number of ` + "`--cosign`" + `s is greater than the number of keys being revoked,
run the command one final time with ` + "`--finish`" + ` instead of ` + "`--cosign`" + `.`,
Exec: runNetworkLockRevokeKeys,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock revoke-keys")
fs.BoolVar(&nlRevokeKeysArgs.cosign, "cosign", false, "continue generating the recovery using the tailnet lock key on this device and the provided recovery blob")
fs.BoolVar(&nlRevokeKeysArgs.finish, "finish", false, "finish the recovery process by transmitting the revocation")
fs.StringVar(&nlRevokeKeysArgs.forkFrom, "fork-from", "", "parent AUM hash to rewrite from (advanced users only)")
return fs
})(),
}
func runNetworkLockRevokeKeys(ctx context.Context, args []string) error {
// First step in the process
if !nlRevokeKeysArgs.cosign && !nlRevokeKeysArgs.finish {
removeKeys, _, err := parseNLArgs(args, true, false)
if err != nil {
return err
}
keyIDs := make([]tkatype.KeyID, len(removeKeys))
for i, k := range removeKeys {
keyIDs[i], err = k.ID()
if err != nil {
return fmt.Errorf("generating keyID: %v", err)
}
}
var forkFrom tka.AUMHash
if nlRevokeKeysArgs.forkFrom != "" {
if len(nlRevokeKeysArgs.forkFrom) == (len(forkFrom) * 2) {
// Hex-encoded: like the output of the lock log command.
b, err := hex.DecodeString(nlRevokeKeysArgs.forkFrom)
if err != nil {
return fmt.Errorf("invalid fork-from hash: %v", err)
}
copy(forkFrom[:], b)
} else {
if err := forkFrom.UnmarshalText([]byte(nlRevokeKeysArgs.forkFrom)); err != nil {
return fmt.Errorf("invalid fork-from hash: %v", err)
}
}
}
aumBytes, err := localClient.NetworkLockGenRecoveryAUM(ctx, keyIDs, forkFrom)
if err != nil {
return fmt.Errorf("generation of recovery AUM failed: %w", err)
}
fmt.Printf(`Run the following command on another machine with a trusted tailnet lock key:
%s lock recover-compromised-key --cosign %X
`, os.Args[0], aumBytes)
return nil
}
// If we got this far, we need to co-sign the AUM and/or transmit it for distribution.
b, err := hex.DecodeString(args[0])
if err != nil {
return fmt.Errorf("parsing hex: %v", err)
}
var recoveryAUM tka.AUM
if err := recoveryAUM.Unserialize(b); err != nil {
return fmt.Errorf("decoding recovery AUM: %v", err)
}
if nlRevokeKeysArgs.cosign {
aumBytes, err := localClient.NetworkLockCosignRecoveryAUM(ctx, recoveryAUM)
if err != nil {
return fmt.Errorf("co-signing recovery AUM failed: %w", err)
}
fmt.Printf(`Co-signing completed successfully.
To accumulate an additional signature, run the following command on another machine with a trusted tailnet lock key:
%s lock recover-compromised-key --cosign %X
Alternatively if you are done with co-signing, complete recovery by running the following command:
%s lock recover-compromised-key --finish %X
`, os.Args[0], aumBytes, os.Args[0], aumBytes)
}
if nlRevokeKeysArgs.finish {
if err := localClient.NetworkLockSubmitRecoveryAUM(ctx, recoveryAUM); err != nil {
return fmt.Errorf("submitting recovery AUM failed: %w", err)
}
fmt.Println("Recovery completed.")
}
return nil
}

View File

@@ -16,6 +16,7 @@ import (
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
@@ -51,14 +52,16 @@ relay node.
fs.BoolVar(&pingArgs.tsmp, "tsmp", false, "do a TSMP-level ping (through WireGuard, but not either host OS stack)")
fs.BoolVar(&pingArgs.icmp, "icmp", false, "do a ICMP-level ping (through WireGuard, but not the local host OS stack)")
fs.BoolVar(&pingArgs.peerAPI, "peerapi", false, "try hitting the peer's peerapi HTTP server")
fs.IntVar(&pingArgs.num, "c", 10, "max number of pings to send")
fs.IntVar(&pingArgs.num, "c", 10, "max number of pings to send. 0 for infinity.")
fs.DurationVar(&pingArgs.timeout, "timeout", 5*time.Second, "timeout before giving up on a ping")
fs.IntVar(&pingArgs.size, "size", 0, "size of the ping message (disco pings only). 0 for minimum size.")
return fs
})(),
}
var pingArgs struct {
num int
size int
untilDirect bool
verbose bool
tsmp bool
@@ -115,7 +118,7 @@ func runPing(ctx context.Context, args []string) error {
for {
n++
ctx, cancel := context.WithTimeout(ctx, pingArgs.timeout)
pr, err := localClient.Ping(ctx, netip.MustParseAddr(ip), pingType())
pr, err := localClient.PingWithOpts(ctx, netip.MustParseAddr(ip), pingType(), tailscale.PingOpts{Size: pingArgs.size})
cancel()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {

View File

@@ -12,6 +12,8 @@ import (
"strings"
"syscall"
"time"
"tailscale.com/util/testenv"
)
var (
@@ -56,7 +58,7 @@ func presentRiskToUser(riskType, riskMessage, acceptedRisks string) error {
if isRiskAccepted(riskType, acceptedRisks) {
return nil
}
if inTest() {
if testenv.InTest() {
return errAborted
}
outln(riskMessage)

View File

@@ -10,6 +10,7 @@ import (
"flag"
"fmt"
"io"
"log"
"net"
"net/url"
"os"
@@ -22,8 +23,11 @@ import (
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/exp/slices"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
"tailscale.com/version"
)
@@ -35,13 +39,14 @@ func newServeCommand(e *serveEnv) *ffcli.Command {
return &ffcli.Command{
Name: "serve",
ShortHelp: "Serve content and local servers",
ShortUsage: strings.TrimSpace(`
serve https:<port> <mount-point> <source> [off]
serve tcp:<port> tcp://localhost:<local-port> [off]
serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off]
serve status [--json]
serve reset
`),
ShortUsage: strings.Join([]string{
"serve http:<port> <mount-point> <source> [off]",
"serve https:<port> <mount-point> <source> [off]",
"serve tcp:<port> tcp://localhost:<local-port> [off]",
"serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off]",
"serve status [--json]",
"serve reset",
}, "\n "),
LongHelp: strings.TrimSpace(`
*** BETA; all of this is subject to change ***
@@ -58,8 +63,8 @@ EXAMPLES
- To proxy requests to a web server at 127.0.0.1:3000:
$ tailscale serve https:443 / http://127.0.0.1:3000
Or, using the default port:
$ tailscale serve https / http://127.0.0.1:3000
Or, using the default port (443):
$ tailscale serve https / http://127.0.0.1:3000
- To serve a single file or a directory of files:
$ tailscale serve https / /home/alice/blog/index.html
@@ -68,6 +73,12 @@ EXAMPLES
- To serve simple static text:
$ tailscale serve https:8080 / text:"Hello, world!"
- To serve over HTTP (tailnet only):
$ tailscale serve http:80 / http://127.0.0.1:3000
Or, using the default port (80):
$ tailscale serve http / http://127.0.0.1:3000
- To forward incoming TCP connections on port 2222 to a local TCP server on
port 22 (e.g. to run OpenSSH in parallel with Tailscale SSH):
$ tailscale serve tcp:2222 tcp://localhost:22
@@ -121,6 +132,9 @@ type localServeClient interface {
Status(context.Context) (*ipnstate.Status, error)
GetServeConfig(context.Context) (*ipn.ServeConfig, error)
SetServeConfig(context.Context, *ipn.ServeConfig) error
QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error)
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error)
IncrementCounter(ctx context.Context, name string, delta int) error
}
// serveEnv is the environment the serve command runs within. All I/O should be
@@ -175,6 +189,7 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status,
// serve config types like proxy, path, and text.
//
// Examples:
// - tailscale serve http / http://localhost:3000
// - tailscale serve https / http://localhost:3000
// - tailscale serve https /images/ /var/www/images/
// - tailscale serve https:10000 /motd.txt text:"Hello, world!"
@@ -199,19 +214,14 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
return e.lc.SetServeConfig(ctx, sc)
}
parsePort := func(portStr string) (uint16, error) {
port64, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return 0, err
}
return uint16(port64), nil
}
srcType, srcPortStr, found := strings.Cut(args[0], ":")
if !found {
if srcType == "https" && srcPortStr == "" {
// Default https port to 443.
srcPortStr = "443"
} else if srcType == "http" && srcPortStr == "" {
// Default http port to 80.
srcPortStr = "80"
} else {
return flag.ErrHelp
}
@@ -219,18 +229,33 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
turnOff := "off" == args[len(args)-1]
if len(args) < 2 || (srcType == "https" && !turnOff && len(args) < 3) {
if len(args) < 2 || ((srcType == "https" || srcType == "http") && !turnOff && len(args) < 3) {
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
return flag.ErrHelp
}
srcPort, err := parsePort(srcPortStr)
if srcType == "https" && !turnOff {
// Running serve with https requires that the tailnet has enabled
// https cert provisioning. Send users through an interactive flow
// to enable this if not already done.
//
// TODO(sonia,tailscale/corp#10577): The interactive feature flow
// is behind a control flag. If the tailnet doesn't have the flag
// on, enableFeatureInteractive will error. For now, we hide that
// error and maintain the previous behavior (prior to 2023-08-15)
// of letting them edit the serve config before enabling certs.
e.enableFeatureInteractive(ctx, "serve", func(caps []string) bool {
return slices.Contains(caps, tailcfg.CapabilityHTTPS)
})
}
srcPort, err := parseServePort(srcPortStr)
if err != nil {
return err
return fmt.Errorf("invalid port %q: %w", srcPortStr, err)
}
switch srcType {
case "https":
case "https", "http":
mount, err := cleanMountPoint(args[1])
if err != nil {
return err
@@ -238,7 +263,8 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
if turnOff {
return e.handleWebServeRemove(ctx, srcPort, mount)
}
return e.handleWebServe(ctx, srcPort, mount, args[2])
useTLS := srcType == "https"
return e.handleWebServe(ctx, srcPort, useTLS, mount, args[2])
case "tcp", "tls-terminated-tcp":
if turnOff {
return e.handleTCPServeRemove(ctx, srcPort)
@@ -246,20 +272,20 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
return e.handleTCPServe(ctx, srcType, srcPort, args[1])
default:
fmt.Fprintf(os.Stderr, "error: invalid serve type %q\n", srcType)
fmt.Fprint(os.Stderr, "must be one of: https:<port>, tcp:<port> or tls-terminated-tcp:<port>\n\n", srcType)
fmt.Fprint(os.Stderr, "must be one of: http:<port>, https:<port>, tcp:<port> or tls-terminated-tcp:<port>\n\n", srcType)
return flag.ErrHelp
}
}
// handleWebServe handles the "tailscale serve https:..." subcommand.
// It configures the serve config to forward HTTPS connections to the
// given source.
// handleWebServe handles the "tailscale serve (http/https):..." subcommand. It
// configures the serve config to forward HTTPS connections to the given source.
//
// Examples:
// - tailscale serve http / http://localhost:3000
// - tailscale serve https / http://localhost:3000
// - tailscale serve https:8443 /files/ /home/alice/shared-files/
// - tailscale serve https:10000 /motd.txt text:"Hello, world!"
func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, source string) error {
func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bool, mount, source string) error {
h := new(ipn.HTTPHandler)
ts, _, _ := strings.Cut(source, ":")
@@ -318,7 +344,7 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, so
return flag.ErrHelp
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: true})
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
@@ -626,7 +652,10 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
printf("\n")
}
for hp := range sc.Web {
printWebStatusTree(sc, hp)
err := e.printWebStatusTree(sc, hp)
if err != nil {
return err
}
printf("\n")
}
printFunnelWarning(sc)
@@ -665,20 +694,37 @@ func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.S
return nil
}
func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) {
func (e *serveEnv) printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) error {
// No-op if no serve config
if sc == nil {
return
return nil
}
fStatus := "tailnet only"
if sc.AllowFunnel[hp] {
fStatus = "Funnel on"
}
host, portStr, _ := net.SplitHostPort(string(hp))
if portStr == "443" {
printf("https://%s (%s)\n", host, fStatus)
} else {
printf("https://%s:%s (%s)\n", host, portStr, fStatus)
port, err := parseServePort(portStr)
if err != nil {
return fmt.Errorf("invalid port %q: %w", portStr, err)
}
scheme := "https"
if sc.IsServingHTTP(port) {
scheme = "http"
}
portPart := ":" + portStr
if scheme == "http" && portStr == "80" ||
scheme == "https" && portStr == "443" {
portPart = ""
}
if scheme == "http" {
hostname, _, _ := strings.Cut(host, ".")
printf("%s://%s%s (%s)\n", scheme, hostname, portPart, fStatus)
}
printf("%s://%s%s (%s)\n", scheme, host, portPart, fStatus)
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
switch {
case h.Path != "":
@@ -705,6 +751,8 @@ func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) {
t, d := srvTypeAndDesc(h)
printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d)
}
return nil
}
func elipticallyTruncate(s string, max int) string {
@@ -725,3 +773,88 @@ func (e *serveEnv) runServeReset(ctx context.Context, args []string) error {
sc := new(ipn.ServeConfig)
return e.lc.SetServeConfig(ctx, sc)
}
// parseServePort parses a port number from a string and returns it as a
// uint16. It returns an error if the port number is invalid or zero.
func parseServePort(s string) (uint16, error) {
p, err := strconv.ParseUint(s, 10, 16)
if err != nil {
return 0, err
}
if p == 0 {
return 0, errors.New("port number must be non-zero")
}
return uint16(p), nil
}
// enableFeatureInteractive sends the node's user through an interactive
// flow to enable a feature, such as Funnel, on their tailnet.
//
// hasRequiredCapabilities should be provided as a function that checks
// whether a slice of node capabilities encloses the necessary values
// needed to use the feature.
//
// If err is returned empty, the feature has been successfully enabled.
//
// If err is returned non-empty, the client failed to query the control
// server for information about how to enable the feature.
//
// If the feature cannot be enabled, enableFeatureInteractive terminates
// the CLI process.
//
// 2023-08-09: The only valid feature values are "serve" and "funnel".
// This can be moved to some CLI lib when expanded past serve/funnel.
func (e *serveEnv) enableFeatureInteractive(ctx context.Context, feature string, hasRequiredCapabilities func(caps []string) bool) (err error) {
info, err := e.lc.QueryFeature(ctx, feature)
if err != nil {
return err
}
if info.Complete {
return nil // already enabled
}
if info.Text != "" {
fmt.Fprintln(os.Stdout, "\n"+info.Text)
}
if info.URL != "" {
fmt.Fprintln(os.Stdout, "\n "+info.URL+"\n")
}
if !info.ShouldWait {
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_not_awaiting_enablement", feature), 1)
// The feature has not been enabled yet,
// but the CLI should not block on user action.
// Once info.Text is printed, exit the CLI.
os.Exit(0)
}
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_awaiting_enablement", feature), 1)
// Block until feature is enabled.
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
watcher, err := e.lc.WatchIPNBus(watchCtx, 0)
if err != nil {
// If we fail to connect to the IPN notification bus,
// don't block. We still present the URL in the CLI,
// then close the process. Swallow the error.
log.Fatalf("lost connection to tailscaled: %v", err)
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
return err
}
defer watcher.Close()
for {
n, err := watcher.Next()
if err != nil {
// Stop blocking if we error.
// Let the user finish enablement then rerun their
// command themselves.
log.Fatalf("lost connection to tailscaled: %v", err)
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
return err
}
if nm := n.NetMap; nm != nil && nm.SelfNode != nil {
if hasRequiredCapabilities(nm.SelfNode.Capabilities) {
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enabled", feature), 1)
fmt.Fprintln(os.Stdout, "Success.")
return nil
}
}
}
}

View File

@@ -6,6 +6,7 @@ package cli
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"os"
@@ -16,6 +17,7 @@ import (
"testing"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
@@ -89,6 +91,59 @@ func TestServeConfigMutations(t *testing.T) {
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
// https
add(step{reset: true})
add(step{ // allow omitting port (default to 80)
command: cmd("http / http://localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // support non Funnel port
command: cmd("http:9999 /abc http://localhost:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 9999: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{
command: cmd("http:9999 /abc off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{
command: cmd("http:8080 /abc http://127.0.0.1:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 8080: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8080": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
// https
add(step{reset: true})
add(step{
@@ -692,14 +747,105 @@ func TestServeConfigMutations(t *testing.T) {
}
}
func TestVerifyFunnelEnabled(t *testing.T) {
lc := &fakeLocalServeClient{}
var stdout bytes.Buffer
var flagOut bytes.Buffer
e := &serveEnv{
lc: lc,
testFlagOut: &flagOut,
testStdout: &stdout,
}
tests := []struct {
name string
// queryFeatureResponse is the mock response desired from the
// call made to lc.QueryFeature by verifyFunnelEnabled.
queryFeatureResponse mockQueryFeatureResponse
caps []string // optionally set at fakeStatus.Capabilities
wantErr string
wantPanic string
}{
{
name: "enabled",
queryFeatureResponse: mockQueryFeatureResponse{resp: &tailcfg.QueryFeatureResponse{Complete: true}, err: nil},
wantErr: "", // no error, success
},
{
name: "fallback-to-non-interactive-flow",
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
wantErr: "Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.",
},
{
name: "fallback-flow-missing-acl-rule",
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
caps: []string{tailcfg.CapabilityHTTPS},
wantErr: `Funnel not available; "funnel" node attribute not set. See https://tailscale.com/s/no-funnel.`,
},
{
name: "fallback-flow-enabled",
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
caps: []string{tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel},
wantErr: "", // no error, success
},
{
name: "not-allowed-to-enable",
queryFeatureResponse: mockQueryFeatureResponse{resp: &tailcfg.QueryFeatureResponse{
Complete: false,
Text: "You don't have permission to enable this feature.",
ShouldWait: false,
}, err: nil},
wantErr: "",
wantPanic: "unexpected call to os.Exit(0) during test", // os.Exit(0) should be called to end process
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
lc.setQueryFeatureResponse(tt.queryFeatureResponse)
if tt.caps != nil {
oldCaps := fakeStatus.Self.Capabilities
defer func() { fakeStatus.Self.Capabilities = oldCaps }() // reset after test
fakeStatus.Self.Capabilities = tt.caps
}
st, err := e.getLocalClientStatus(ctx)
if err != nil {
t.Fatal(err)
}
defer func() {
r := recover()
var gotPanic string
if r != nil {
gotPanic = fmt.Sprint(r)
}
if gotPanic != tt.wantPanic {
t.Errorf("wrong panic; got=%s, want=%s", gotPanic, tt.wantPanic)
}
}()
gotErr := e.verifyFunnelEnabled(ctx, st, 443)
var got string
if gotErr != nil {
got = gotErr.Error()
}
if got != tt.wantErr {
t.Errorf("wrong error; got=%s, want=%s", gotErr, tt.wantErr)
}
})
}
}
// fakeLocalServeClient is a fake tailscale.LocalClient for tests.
// It's not a full implementation, just enough to test the serve command.
//
// The fake client is stateful, and is used to test manipulating
// ServeConfig state. This implementation cannot be used concurrently.
type fakeLocalServeClient struct {
config *ipn.ServeConfig
setCount int // counts calls to SetServeConfig
config *ipn.ServeConfig
setCount int // counts calls to SetServeConfig
queryFeatureResponse *mockQueryFeatureResponse // mock response to QueryFeature calls
}
// fakeStatus is a fake ipnstate.Status value for tests.
@@ -729,6 +875,31 @@ func (lc *fakeLocalServeClient) SetServeConfig(ctx context.Context, config *ipn.
return nil
}
type mockQueryFeatureResponse struct {
resp *tailcfg.QueryFeatureResponse
err error
}
func (lc *fakeLocalServeClient) setQueryFeatureResponse(resp mockQueryFeatureResponse) {
lc.queryFeatureResponse = &resp
}
func (lc *fakeLocalServeClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
if resp := lc.queryFeatureResponse; resp != nil {
// If we're testing QueryFeature, use the response value set for the test.
return resp.resp, resp.err
}
return &tailcfg.QueryFeatureResponse{Complete: true}, nil // fallback to already enabled
}
func (lc *fakeLocalServeClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error) {
return nil, nil // unused in tests
}
func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name string, delta int) error {
return nil // unused in tests
}
// exactError returns an error checker that wants exactly the provided want error.
// If optName is non-empty, it's used in the error message.
func exactErr(want error, optName ...string) func(error) string {

View File

@@ -12,6 +12,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
"tailscale.com/safesocket"
)
@@ -159,11 +160,11 @@ func runSet(ctx context.Context, args []string) (retErr error) {
// setArgs is the parsed command-line arguments.
func calcAdvertiseRoutesForSet(advertiseExitNodeSet, advertiseRoutesSet bool, curPrefs *ipn.Prefs, setArgs setArgsT) (routes []netip.Prefix, err error) {
if advertiseExitNodeSet && advertiseRoutesSet {
return calcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute)
return netutil.CalcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute)
}
if advertiseRoutesSet {
return calcAdvertiseRoutes(setArgs.advertiseRoutes, curPrefs.AdvertisesExitNode())
return netutil.CalcAdvertiseRoutes(setArgs.advertiseRoutes, curPrefs.AdvertisesExitNode())
}
if advertiseExitNodeSet {
alreadyAdvertisesExitNode := curPrefs.AdvertisesExitNode()

View File

@@ -200,6 +200,8 @@ func runStatus(ctx context.Context, args []string) error {
if statusArgs.self && st.Self != nil {
printPS(st.Self)
}
locBasedExitNode := false
if statusArgs.peers {
var peers []*ipnstate.PeerStatus
for _, peer := range st.Peers() {
@@ -207,6 +209,12 @@ func runStatus(ctx context.Context, args []string) error {
if ps.ShareeNode {
continue
}
if ps.Location != nil && ps.ExitNodeOption && !ps.ExitNode {
// Location based exit nodes are only shown with the
// `exit-node list` command.
locBasedExitNode = true
continue
}
peers = append(peers, ps)
}
ipnstate.SortPeers(peers)
@@ -218,6 +226,10 @@ func runStatus(ctx context.Context, args []string) error {
}
}
Stdout.Write(buf.Bytes())
if locBasedExitNode {
println()
println("# To see the full list of exit nodes, including location-based exit nodes, run `tailscale exit-node list` \n")
}
if len(st.Health) > 0 {
outln()
printHealth()

View File

@@ -6,7 +6,6 @@ package cli
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"flag"
@@ -30,11 +29,10 @@ import (
qrcode "github.com/skip2/go-qrcode"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/health/healthmsg"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsaddr"
"tailscale.com/net/netutil"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
@@ -92,8 +90,6 @@ func acceptRouteDefault(goos string) bool {
var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgsGlobal, "up")
func inTest() bool { return flag.Lookup("test.v") != nil }
// newUpFlagSet returns a new flag set for the "up" and "login" commands.
func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
if cmd != "up" && cmd != "login" {
@@ -223,82 +219,6 @@ func warnf(format string, args ...any) {
printf("Warning: "+format+"\n", args...)
}
var (
ipv4default = netip.MustParsePrefix("0.0.0.0/0")
ipv6default = netip.MustParsePrefix("::/0")
)
func validateViaPrefix(ipp netip.Prefix) error {
if !tsaddr.IsViaPrefix(ipp) {
return fmt.Errorf("%v is not a 4-in-6 prefix", ipp)
}
if ipp.Bits() < (128 - 32) {
return fmt.Errorf("%v 4-in-6 prefix must be at least a /%v", ipp, 128-32)
}
a := ipp.Addr().As16()
// The first 64 bits of a are the via prefix.
// The next 32 bits are the "site ID".
// The last 32 bits are the IPv4.
// For now, we reserve the top 3 bytes of the site ID,
// and only allow users to use site IDs 0-255.
siteID := binary.BigEndian.Uint32(a[8:12])
if siteID > 0xFF {
return fmt.Errorf("route %v contains invalid site ID %08x; must be 0xff or less", ipp, siteID)
}
return nil
}
func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]netip.Prefix, error) {
routeMap := map[netip.Prefix]bool{}
if advertiseRoutes != "" {
var default4, default6 bool
advroutes := strings.Split(advertiseRoutes, ",")
for _, s := range advroutes {
ipp, err := netip.ParsePrefix(s)
if err != nil {
return nil, fmt.Errorf("%q is not a valid IP address or CIDR prefix", s)
}
if ipp != ipp.Masked() {
return nil, fmt.Errorf("%s has non-address bits set; expected %s", ipp, ipp.Masked())
}
if tsaddr.IsViaPrefix(ipp) {
if err := validateViaPrefix(ipp); err != nil {
return nil, err
}
}
if ipp == ipv4default {
default4 = true
} else if ipp == ipv6default {
default6 = true
}
routeMap[ipp] = true
}
if default4 && !default6 {
return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv4default, ipv6default)
} else if default6 && !default4 {
return nil, fmt.Errorf("%s advertised without its IPv4 counterpart, please also advertise %s", ipv6default, ipv4default)
}
}
if advertiseDefaultRoute {
routeMap[netip.MustParsePrefix("0.0.0.0/0")] = true
routeMap[netip.MustParsePrefix("::/0")] = true
}
if len(routeMap) == 0 {
return nil, nil
}
routes := make([]netip.Prefix, 0, len(routeMap))
for r := range routeMap {
routes = append(routes, r)
}
sort.Slice(routes, func(i, j int) bool {
if routes[i].Bits() != routes[j].Bits() {
return routes[i].Bits() < routes[j].Bits()
}
return routes[i].Addr().Less(routes[j].Addr())
})
return routes, nil
}
// prefsFromUpArgs returns the ipn.Prefs for the provided args.
//
// Note that the parameters upArgs and warnf are named intentionally
@@ -306,7 +226,7 @@ func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]
// function exists for testing and should have no side effects or
// outside interactions (e.g. no making Tailscale LocalAPI calls).
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
routes, err := calcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
routes, err := netutil.CalcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
if err != nil {
return nil, err
}
@@ -425,7 +345,7 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
simpleUp = env.flagSet.NFlag() == 0 &&
curPrefs.Persist != nil &&
curPrefs.Persist.LoginName != "" &&
curPrefs.Persist.UserProfile.LoginName != "" &&
env.backendState != ipn.NeedsLogin.String()
justEdit := env.backendState == ipn.Running.String() &&
@@ -726,7 +646,8 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
// the health check, rather than just a string.
func upWorthyWarning(s string) bool {
return strings.Contains(s, healthmsg.TailscaleSSHOnBut) ||
strings.Contains(s, healthmsg.WarnAcceptRoutesOff)
strings.Contains(s, healthmsg.WarnAcceptRoutesOff) ||
strings.Contains(s, healthmsg.LockedOut)
}
func checkUpWarnings(ctx context.Context) {
@@ -1132,9 +1053,6 @@ func resolveAuthKey(ctx context.Context, v, tags string) (string, error) {
if !strings.HasPrefix(v, "tskey-client-") {
return v, nil
}
if !envknob.Bool("TS_EXPERIMENT_OAUTH_AUTHKEY") {
return "", errors.New("oauth authkeys are in experimental status")
}
if tags == "" {
return "", errors.New("oauth authkeys require --advertise-tags")
}

View File

@@ -4,33 +4,15 @@
package cli
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/net/tshttpproxy"
"tailscale.com/util/must"
"tailscale.com/util/winutil"
"tailscale.com/clientupdate"
"tailscale.com/version"
"tailscale.com/version/distro"
)
@@ -44,137 +26,61 @@ var updateCmd = &ffcli.Command{
fs := newFlagSet("update")
fs.BoolVar(&updateArgs.yes, "yes", false, "update without interactive prompts")
fs.BoolVar(&updateArgs.dryRun, "dry-run", false, "print what update would do without doing it, or prompts")
fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`)
fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`)
fs.BoolVar(&updateArgs.appStore, "app-store", false, "HIDDEN: check the App Store for updates, even if this is not an App Store install (for testing only)")
// These flags are not supported on several systems that only provide
// the latest version of Tailscale:
//
// - Arch (and other pacman-based distros)
// - Alpine (and other apk-based distros)
// - FreeBSD (and other pkg-based distros)
if distro.Get() != distro.Arch && distro.Get() != distro.Alpine && runtime.GOOS != "freebsd" {
fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`)
fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`)
}
return fs
})(),
}
var updateArgs struct {
yes bool
dryRun bool
track string // explicit track; empty means same as current
version string // explicit version; empty means auto
yes bool
dryRun bool
appStore bool
track string // explicit track; empty means same as current
version string // explicit version; empty means auto
}
// winMSIEnv is the environment variable that, if set, is the MSI file for the
// update command to install. It's passed like this so we can stop the
// tailscale.exe process from running before the msiexec process runs and tries
// to overwrite ourselves.
const winMSIEnv = "TS_UPDATE_WIN_MSI"
func runUpdate(ctx context.Context, args []string) error {
if msi := os.Getenv(winMSIEnv); msi != "" {
log.Printf("installing %v ...", msi)
if err := installMSI(msi); err != nil {
log.Printf("MSI install failed: %v", err)
return err
}
log.Printf("success.")
return nil
}
if len(args) > 0 {
return flag.ErrHelp
}
if updateArgs.version != "" && updateArgs.track != "" {
return errors.New("cannot specify both --version and --track")
}
up, err := newUpdater()
if err != nil {
return err
ver := updateArgs.version
if updateArgs.track != "" {
ver = updateArgs.track
}
return up.update()
err := clientupdate.Update(clientupdate.UpdateArgs{
Version: ver,
AppStore: updateArgs.appStore,
Logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
Confirm: confirmUpdate,
})
if errors.Is(err, errors.ErrUnsupported) {
return errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
}
return err
}
func versionIsStable(v string) (stable, wellFormed bool) {
_, rest, ok := strings.Cut(v, ".")
if !ok {
return false, false
}
minorStr, _, ok := strings.Cut(rest, ".")
if !ok {
return false, false
}
minor, err := strconv.Atoi(minorStr)
if err != nil {
return false, false
}
return minor%2 == 0, true
}
func newUpdater() (*updater, error) {
up := &updater{
track: updateArgs.track,
}
switch up.track {
case "stable", "unstable":
case "":
if version.IsUnstableBuild() {
up.track = "unstable"
} else {
up.track = "stable"
}
if updateArgs.version != "" {
stable, ok := versionIsStable(updateArgs.version)
if !ok {
return nil, fmt.Errorf("malformed version %q", updateArgs.version)
}
if stable {
up.track = "stable"
} else {
up.track = "unstable"
}
}
default:
return nil, fmt.Errorf("unknown track %q; must be 'stable' or 'unstable'", up.track)
}
switch runtime.GOOS {
case "windows":
up.update = up.updateWindows
case "linux":
switch distro.Get() {
case distro.Synology:
up.update = up.updateSynology
case distro.Debian: // includes Ubuntu
up.update = up.updateDebLike
}
case "darwin":
switch {
case !version.IsSandboxedMacOS():
return nil, errors.New("The 'update' command is not yet supported on this platform; see https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS/ for now")
case strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
up.update = up.updateMacSys
default:
return nil, errors.New("This is the macOS App Store version of Tailscale; update in the App Store, or see https://tailscale.com/s/unstable-clients to use TestFlight or to install the non-App Store version")
}
}
if up.update == nil {
return nil, errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
}
return up, nil
}
type updater struct {
track string
update func() error
}
func (up *updater) currentOrDryRun(ver string) bool {
if version.Short() == ver {
fmt.Printf("already running %v; no update needed\n", ver)
func confirmUpdate(ver string) bool {
if updateArgs.yes {
fmt.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver)
return true
}
if updateArgs.dryRun {
fmt.Printf("Current: %v, Latest: %v\n", version.Short(), ver)
return true
}
return false
}
func (up *updater) confirm(ver string) error {
if updateArgs.yes {
log.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver)
return nil
return false
}
fmt.Printf("This will update Tailscale from %v to %v. Continue? [y/n] ", version.Short(), ver)
@@ -183,405 +89,7 @@ func (up *updater) confirm(ver string) error {
resp = strings.ToLower(resp)
switch resp {
case "y", "yes", "sure":
return nil
return true
}
return errors.New("aborting update")
}
func (up *updater) updateSynology() error {
// TODO(bradfitz): detect, map GOARCH+CPU to the right Synology arch.
// TODO(bradfitz): add pkgs.tailscale.com endpoint to get release info
// TODO(bradfitz): require root/sudo
// TODO(bradfitz): run /usr/syno/bin/synopkg install tailscale.spk
return errors.New("The 'update' command is not yet implemented on Synology.")
}
func (up *updater) updateDebLike() error {
ver := updateArgs.version
if ver == "" {
res, err := http.Get("https://pkgs.tailscale.com/" + up.track + "/?mode=json")
if err != nil {
return err
}
var latest struct {
Tarballs map[string]string // ~goarch (ignoring "geode") => "tailscale_1.34.2_mips.tgz"
}
err = json.NewDecoder(res.Body).Decode(&latest)
res.Body.Close()
if err != nil {
return fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
}
f, ok := latest.Tarballs[runtime.GOARCH]
if !ok {
return fmt.Errorf("can't update architecture %q", runtime.GOARCH)
}
ver, _, ok = strings.Cut(strings.TrimPrefix(f, "tailscale_"), "_")
if !ok {
return fmt.Errorf("can't parse version from %q", f)
}
}
if up.currentOrDryRun(ver) {
return nil
}
track := "unstable"
if stable, ok := versionIsStable(ver); !ok {
return fmt.Errorf("malformed version %q", ver)
} else if stable {
track = "stable"
}
if os.Geteuid() != 0 {
return errors.New("must be root; use sudo")
}
if updated, err := updateDebianAptSourcesList(track); err != nil {
return err
} else if updated {
fmt.Printf("Updated %s to use the %s track\n", aptSourcesFile, track)
}
cmd := exec.Command("apt-get", "update",
// Only update the tailscale repo, not the other ones, treating
// the tailscale.list file as the main "sources.list" file.
"-o", "Dir::Etc::SourceList=sources.list.d/tailscale.list",
// Disable the "sources.list.d" directory:
"-o", "Dir::Etc::SourceParts=-",
// Don't forget about packages in the other repos just because
// we're not updating them:
"-o", "APT::Get::List-Cleanup=0",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return nil
}
const aptSourcesFile = "/etc/apt/sources.list.d/tailscale.list"
// updateDebianAptSourcesList updates the /etc/apt/sources.list.d/tailscale.list
// file to make sure it has the provided track (stable or unstable) in it.
//
// If it already has the right track (including containing both stable and
// unstable), it does nothing.
func updateDebianAptSourcesList(dstTrack string) (rewrote bool, err error) {
was, err := os.ReadFile(aptSourcesFile)
if err != nil {
return false, err
}
newContent, err := updateDebianAptSourcesListBytes(was, dstTrack)
if err != nil {
return false, err
}
if bytes.Equal(was, newContent) {
return false, nil
}
return true, os.WriteFile(aptSourcesFile, newContent, 0644)
}
func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []byte, err error) {
trackURLPrefix := []byte("https://pkgs.tailscale.com/" + dstTrack + "/")
var buf bytes.Buffer
var changes int
bs := bufio.NewScanner(bytes.NewReader(was))
hadCorrect := false
commentLine := regexp.MustCompile(`^\s*\#`)
pkgsURL := regexp.MustCompile(`\bhttps://pkgs\.tailscale\.com/((un)?stable)/`)
for bs.Scan() {
line := bs.Bytes()
if !commentLine.Match(line) {
line = pkgsURL.ReplaceAllFunc(line, func(m []byte) []byte {
if bytes.Equal(m, trackURLPrefix) {
hadCorrect = true
} else {
changes++
}
return trackURLPrefix
})
}
buf.Write(line)
buf.WriteByte('\n')
}
if hadCorrect || (changes == 1 && bytes.Equal(bytes.TrimSpace(was), bytes.TrimSpace(buf.Bytes()))) {
// Unchanged or close enough.
return was, nil
}
if changes != 1 {
// No changes, or an unexpected number of changes (what?). Bail.
// They probably editted it by hand and we don't know what to do.
return nil, fmt.Errorf("unexpected/unsupported %s contents", aptSourcesFile)
}
return buf.Bytes(), nil
}
func (up *updater) updateMacSys() error {
// use sparkle? do we have permissions from this context? does sudo help?
// We can at least fail with a command they can run to update from the shell.
// Like "tailscale update --macsys | sudo sh" or something.
//
// TODO(bradfitz,mihai): implement. But for now:
return errors.New("The 'update' command is not yet implemented on macOS.")
}
var (
verifyAuthenticode func(string) error // or nil on non-Windows
markTempFileFunc func(string) error // or nil on non-Windows
)
func (up *updater) updateWindows() error {
ver := updateArgs.version
if ver == "" {
res, err := http.Get("https://pkgs.tailscale.com/" + up.track + "/?mode=json&os=windows")
if err != nil {
return err
}
var latest struct {
Version string
}
err = json.NewDecoder(res.Body).Decode(&latest)
res.Body.Close()
if err != nil {
return fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
}
ver = latest.Version
if ver == "" {
return errors.New("no version found")
}
}
arch := runtime.GOARCH
if arch == "386" {
arch = "x86"
}
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
if up.currentOrDryRun(ver) {
return nil
}
if !winutil.IsCurrentProcessElevated() {
return errors.New("must be run as Administrator")
}
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
msiDir := filepath.Join(tsDir, "MSICache")
if fi, err := os.Stat(tsDir); err != nil {
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
} else if !fi.IsDir() {
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
}
if err := os.MkdirAll(msiDir, 0700); err != nil {
return err
}
if err := up.confirm(ver); err != nil {
return err
}
msiTarget := filepath.Join(msiDir, path.Base(url))
if err := downloadURLToFile(url, msiTarget); err != nil {
return err
}
log.Printf("verifying MSI authenticode...")
if err := verifyAuthenticode(msiTarget); err != nil {
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
}
log.Printf("authenticode verification succeeded")
log.Printf("making tailscale.exe copy to switch to...")
selfCopy, err := makeSelfCopy()
if err != nil {
return err
}
defer os.Remove(selfCopy)
log.Printf("running tailscale.exe copy for final install...")
cmd := exec.Command(selfCopy, "update")
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
return err
}
// Once it's started, exit ourselves, so the binary is free
// to be replaced.
os.Exit(0)
panic("unreachable")
}
func installMSI(msi string) error {
var err error
for tries := 0; tries < 2; tries++ {
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
cmd.Dir = filepath.Dir(msi)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
if err == nil {
break
}
uninstallVersion := version.Short()
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
uninstallVersion = v
}
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
log.Printf("Uninstalling current version %q for downgrade...", uninstallVersion)
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
log.Printf("msiexec uninstall: %v", err)
}
return err
}
func msiUUIDForVersion(ver string) string {
arch := runtime.GOARCH
if arch == "386" {
arch = "x86"
}
track := "unstable"
if stable, ok := versionIsStable(ver); ok && stable {
track = "stable"
}
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
}
func makeSelfCopy() (tmpPathExe string, err error) {
selfExe, err := os.Executable()
if err != nil {
return "", err
}
f, err := os.Open(selfExe)
if err != nil {
return "", err
}
defer f.Close()
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
if err != nil {
return "", err
}
if f := markTempFileFunc; f != nil {
if err := f(f2.Name()); err != nil {
return "", err
}
}
if _, err := io.Copy(f2, f); err != nil {
f2.Close()
return "", err
}
return f2.Name(), f2.Close()
}
func downloadURLToFile(urlSrc, fileDst string) (ret error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
defer tr.CloseIdleConnections()
c := &http.Client{Transport: tr}
quickCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
headReq := must.Get(http.NewRequestWithContext(quickCtx, "HEAD", urlSrc, nil))
res, err := c.Do(headReq)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("HEAD %s: %v", urlSrc, res.Status)
}
if res.ContentLength <= 0 {
return fmt.Errorf("HEAD %s: unexpected Content-Length %v", urlSrc, res.ContentLength)
}
log.Printf("Download size: %v", res.ContentLength)
hashReq := must.Get(http.NewRequestWithContext(quickCtx, "GET", urlSrc+".sha256", nil))
hashRes, err := c.Do(hashReq)
if err != nil {
return err
}
hashHex, err := io.ReadAll(io.LimitReader(hashRes.Body, 100))
hashRes.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s.sha256: %v", urlSrc, res.Status)
}
if err != nil {
return err
}
wantHash, err := hex.DecodeString(string(strings.TrimSpace(string(hashHex))))
if err != nil {
return err
}
hash := sha256.New()
dlReq := must.Get(http.NewRequestWithContext(context.Background(), "GET", urlSrc, nil))
dlRes, err := c.Do(dlReq)
if err != nil {
return err
}
// TODO(bradfitz): resume from existing partial file on disk
if dlRes.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: %v", urlSrc, dlRes.Status)
}
of, err := os.Create(fileDst)
if err != nil {
return err
}
defer func() {
if ret != nil {
of.Close()
// TODO(bradfitz): os.Remove(fileDst) too? or keep it to resume from/debug later.
}
}()
pw := &progressWriter{total: res.ContentLength}
n, err := io.Copy(io.MultiWriter(hash, of, pw), io.LimitReader(dlRes.Body, res.ContentLength))
if err != nil {
return err
}
if n != res.ContentLength {
return fmt.Errorf("downloaded %v; want %v", n, res.ContentLength)
}
if err := of.Close(); err != nil {
return err
}
pw.print()
if !bytes.Equal(hash.Sum(nil), wantHash) {
return fmt.Errorf("SHA-256 of downloaded MSI didn't match expected value")
}
log.Printf("hash matched")
return nil
}
type progressWriter struct {
done int64
total int64
lastPrint time.Time
}
func (pw *progressWriter) Write(p []byte) (n int, err error) {
pw.done += int64(len(p))
if time.Since(pw.lastPrint) > 2*time.Second {
pw.print()
}
return len(p), nil
}
func (pw *progressWriter) print() {
pw.lastPrint = time.Now()
log.Printf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
return false
}

View File

@@ -1,75 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import "testing"
func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
tests := []struct {
name string
toTrack string
in string
want string // empty means want no change
wantErr string
}{
{
name: "stable-to-unstable",
toTrack: "unstable",
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
want: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "stable-unchanged",
toTrack: "stable",
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
},
{
name: "if-both-stable-and-unstable-dont-change",
toTrack: "stable",
in: "# Tailscale packages for debian buster\n" +
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "if-both-stable-and-unstable-dont-change-unstable",
toTrack: "unstable",
in: "# Tailscale packages for debian buster\n" +
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "signed-by-form",
toTrack: "unstable",
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n",
want: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n",
},
{
name: "unsupported-lines",
toTrack: "unstable",
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n",
wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newContent, err := updateDebianAptSourcesListBytes([]byte(tt.in), tt.toTrack)
if err != nil {
if err.Error() != tt.wantErr {
t.Fatalf("error = %v; want %q", err, tt.wantErr)
}
return
}
if tt.wantErr != "" {
t.Fatalf("got no error; want %q", tt.wantErr)
}
var gotChange string
if string(newContent) != tt.in {
gotChange = string(newContent)
}
if gotChange != tt.want {
t.Errorf("wrong result\n got: %q\nwant: %q", gotChange, tt.want)
}
})
}
}

View File

@@ -11,6 +11,7 @@ import (
"os"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/clientupdate"
"tailscale.com/ipn/ipnstate"
"tailscale.com/version"
)
@@ -23,14 +24,16 @@ var versionCmd = &ffcli.Command{
fs := newFlagSet("version")
fs.BoolVar(&versionArgs.daemon, "daemon", false, "also print local node's daemon version")
fs.BoolVar(&versionArgs.json, "json", false, "output in JSON format")
fs.BoolVar(&versionArgs.upstream, "upstream", false, "fetch and print the latest upstream release version from pkgs.tailscale.com")
return fs
})(),
Exec: runVersion,
}
var versionArgs struct {
daemon bool // also check local node's daemon version
json bool
daemon bool // also check local node's daemon version
json bool
upstream bool
}
func runVersion(ctx context.Context, args []string) error {
@@ -47,21 +50,42 @@ func runVersion(ctx context.Context, args []string) error {
}
}
var upstreamVer string
if versionArgs.upstream {
upstreamVer, err = clientupdate.LatestTailscaleVersion(clientupdate.CurrentTrack)
if err != nil {
return err
}
}
if versionArgs.json {
m := version.GetMeta()
if st != nil {
m.DaemonLong = st.Version
}
out := struct {
version.Meta
Upstream string `json:"upstream,omitempty"`
}{
Meta: m,
Upstream: upstreamVer,
}
e := json.NewEncoder(os.Stdout)
e.SetIndent("", "\t")
return e.Encode(m)
return e.Encode(out)
}
if st == nil {
outln(version.String())
if versionArgs.upstream {
printf(" upstream: %s\n", upstreamVer)
}
return nil
}
printf("Client: %s\n", version.String())
printf("Daemon: %s\n", st.Version)
if versionArgs.upstream {
printf("Upstream: %s\n", upstreamVer)
}
return nil
}

View File

@@ -4,75 +4,23 @@
package cli
import (
"bytes"
"context"
"crypto/tls"
_ "embed"
"encoding/json"
"encoding/xml"
"flag"
"fmt"
"html/template"
"io"
"log"
"net"
"net/http"
"net/http/cgi"
"net/netip"
"net/url"
"os"
"os/exec"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/groupmember"
"tailscale.com/version/distro"
"tailscale.com/client/web"
"tailscale.com/util/cmpx"
)
//go:embed web.html
var webHTML string
//go:embed web.css
var webCSS string
//go:embed auth-redirect.html
var authenticationRedirectHTML string
var tmpl *template.Template
func init() {
tmpl = template.Must(template.New("web.html").Parse(webHTML))
template.Must(tmpl.New("web.css").Parse(webCSS))
}
type tmplData struct {
Profile tailcfg.UserProfile
SynologyUser string
Status string
DeviceName string
IP string
AdvertiseExitNode bool
AdvertiseRoutes string
LicensesURL string
TUNMode bool
IsSynology bool
DSMVersion int // 6 or 7, if IsSynology=true
IsUnraid bool
UnraidToken string
IPNVersion string
}
type postedData struct {
AdvertiseRoutes string
AdvertiseExitNode bool
Reauthenticate bool
ForceLogout bool
}
var webCmd = &ffcli.Command{
Name: "web",
ShortUsage: "web [flags]",
@@ -90,6 +38,7 @@ Tailscale, as opposed to a CLI or a native app.
webf := newFlagSet("web")
webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic")
webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
webf.BoolVar(&webArgs.dev, "dev", false, "run web client in developer mode [this flag is in development, use is unsupported]")
return webf
})(),
Exec: runWeb,
@@ -98,6 +47,7 @@ Tailscale, as opposed to a CLI or a native app.
var webArgs struct {
listen string
cgi bool
dev bool
}
func tlsConfigFromEnvironment() *tls.Config {
@@ -128,8 +78,11 @@ func runWeb(ctx context.Context, args []string) error {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
webServer, cleanup := web.NewServer(webArgs.dev, nil)
defer cleanup()
if webArgs.cgi {
if err := cgi.Serve(http.HandlerFunc(webHandler)); err != nil {
if err := cgi.Serve(webServer); err != nil {
log.Printf("tailscale.cgi: %v", err)
return err
}
@@ -141,391 +94,19 @@ func runWeb(ctx context.Context, args []string) error {
server := &http.Server{
Addr: webArgs.listen,
TLSConfig: tlsConfig,
Handler: http.HandlerFunc(webHandler),
Handler: webServer,
}
log.Printf("web server running on: https://%s", server.Addr)
return server.ListenAndServeTLS("", "")
} else {
log.Printf("web server running on: %s", urlOfListenAddr(webArgs.listen))
return http.ListenAndServe(webArgs.listen, http.HandlerFunc(webHandler))
return http.ListenAndServe(webArgs.listen, webServer)
}
}
// urlOfListenAddr parses a given listen address into a formatted URL
func urlOfListenAddr(addr string) string {
host, port, _ := net.SplitHostPort(addr)
if host == "" {
host = "127.0.0.1"
}
return fmt.Sprintf("http://%s", net.JoinHostPort(host, port))
}
// authorize returns the name of the user accessing the web UI after verifying
// whether the user has access to the web UI. The function will write the
// error to the provided http.ResponseWriter.
// Note: This is different from a tailscale user, and is typically the local
// user on the node.
func authorize(w http.ResponseWriter, r *http.Request) (string, error) {
switch distro.Get() {
case distro.Synology:
user, err := synoAuthn()
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return "", err
}
if err := authorizeSynology(user); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return "", err
}
return user, nil
case distro.QNAP:
user, resp, err := qnapAuthn(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return "", err
}
if resp.IsAdmin == 0 {
http.Error(w, err.Error(), http.StatusForbidden)
return "", err
}
return user, nil
}
return "", nil
}
// authorizeSynology checks whether the provided user has access to the web UI
// by consulting the membership of the "administrators" group.
func authorizeSynology(name string) error {
yes, err := groupmember.IsMemberOfGroup("administrators", name)
if err != nil {
return err
}
if !yes {
return fmt.Errorf("not a member of administrators group")
}
return nil
}
type qnapAuthResponse struct {
AuthPassed int `xml:"authPassed"`
IsAdmin int `xml:"isAdmin"`
AuthSID string `xml:"authSid"`
ErrorValue int `xml:"errorValue"`
}
func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
user, err := r.Cookie("NAS_USER")
if err != nil {
return "", nil, err
}
token, err := r.Cookie("qtoken")
if err == nil {
return qnapAuthnQtoken(r, user.Value, token.Value)
}
sid, err := r.Cookie("NAS_SID")
if err == nil {
return qnapAuthnSid(r, user.Value, sid.Value)
}
return "", nil, fmt.Errorf("not authenticated by any mechanism")
}
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
// running based on the request URL. This is necessary because QNAP has so
// many options, see https://github.com/tailscale/tailscale/issues/7108
// and https://github.com/tailscale/tailscale/issues/6903
func qnapAuthnURL(requestUrl string, query url.Values) string {
in, err := url.Parse(requestUrl)
scheme := ""
host := ""
if err != nil || in.Scheme == "" {
log.Printf("Cannot parse QNAP login URL %v", err)
// try localhost and hope for the best
scheme = "http"
host = "localhost"
} else {
scheme = in.Scheme
host = in.Host
}
u := url.URL{
Scheme: scheme,
Host: host,
Path: "/cgi-bin/authLogin.cgi",
RawQuery: query.Encode(),
}
return u.String()
}
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
query := url.Values{
"qtoken": []string{token},
"user": []string{user},
}
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
}
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
query := url.Values{
"sid": []string{sid},
}
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
}
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
// SAN. See https://github.com/tailscale/tailscale/issues/6903
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
resp, err := client.Get(url)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, err
}
authResp := &qnapAuthResponse{}
if err := xml.Unmarshal(out, authResp); err != nil {
return "", nil, err
}
if authResp.AuthPassed == 0 {
return "", nil, fmt.Errorf("not authenticated")
}
return user, authResp, nil
}
func synoAuthn() (string, error) {
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("auth: %v: %s", err, out)
}
return strings.TrimSpace(string(out)), nil
}
func authRedirect(w http.ResponseWriter, r *http.Request) bool {
if distro.Get() == distro.Synology {
return synoTokenRedirect(w, r)
}
return false
}
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" {
return false
}
if r.URL.Query().Get("SynoToken") != "" {
return false
}
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
return false
}
// We need a SynoToken for authenticate.cgi.
// So we tell the client to get one.
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
return true
}
const synoTokenRedirectHTML = `<html><body>
Redirecting with session token...
<script>
var serverURL = window.location.protocol + "//" + window.location.host;
var req = new XMLHttpRequest();
req.overrideMimeType("application/json");
req.open("GET", serverURL + "/webman/login.cgi", true);
req.onload = function() {
var jsonResponse = JSON.parse(req.responseText);
var token = jsonResponse["SynoToken"];
document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
};
req.send(null);
</script>
</body></html>
`
func webHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if authRedirect(w, r) {
return
}
user, err := authorize(w, r)
if err != nil {
return
}
if r.URL.Path == "/redirect" || r.URL.Path == "/redirect/" {
io.WriteString(w, authenticationRedirectHTML)
return
}
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
prefs, err := localClient.GetPrefs(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Method == "POST" {
defer r.Body.Close()
var postData postedData
type mi map[string]any
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
w.WriteHeader(400)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
routes, err := calcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
mp := &ipn.MaskedPrefs{
AdvertiseRoutesSet: true,
WantRunningSet: true,
}
mp.Prefs.WantRunning = true
mp.Prefs.AdvertiseRoutes = routes
log.Printf("Doing edit: %v", mp.Pretty())
if _, err := localClient.EditPrefs(ctx, mp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
var reauth, logout bool
if postData.Reauthenticate {
reauth = true
}
if postData.ForceLogout {
logout = true
}
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
url, err := tailscaleUp(r.Context(), st, postData)
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
if url != "" {
json.NewEncoder(w).Encode(mi{"url": url})
} else {
io.WriteString(w, "{}")
}
return
}
profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0]
versionShort := strings.Split(st.Version, "-")[0]
data := tmplData{
SynologyUser: user,
Profile: profile,
Status: st.BackendState,
DeviceName: deviceName,
LicensesURL: licensesURL(),
TUNMode: st.TUN,
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
DSMVersion: distro.DSMVersion(),
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
IPNVersion: versionShort,
}
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertiseExitNode = true
} else {
if data.AdvertiseRoutes != "" {
data.AdvertiseRoutes += ","
}
data.AdvertiseRoutes += r.String()
}
}
if len(st.TailscaleIPs) != 0 {
data.IP = st.TailscaleIPs[0].String()
}
buf := new(bytes.Buffer)
if err := tmpl.Execute(buf, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(buf.Bytes())
}
func tailscaleUp(ctx context.Context, st *ipnstate.Status, postData postedData) (authURL string, retErr error) {
if postData.ForceLogout {
if err := localClient.Logout(ctx); err != nil {
return "", fmt.Errorf("Logout error: %w", err)
}
return "", nil
}
origAuthURL := st.AuthURL
isRunning := st.BackendState == ipn.Running.String()
forceReauth := postData.Reauthenticate
if !forceReauth {
if origAuthURL != "" {
return origAuthURL, nil
}
if isRunning {
return "", nil
}
}
// printAuthURL reports whether we should print out the
// provided auth URL from an IPN notify.
printAuthURL := func(url string) bool {
return url != origAuthURL
}
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
watcher, err := localClient.WatchIPNBus(watchCtx, 0)
if err != nil {
return "", err
}
defer watcher.Close()
go func() {
if !isRunning {
localClient.Start(ctx, ipn.Options{})
}
if forceReauth {
localClient.StartLoginInteractive(ctx)
}
}()
for {
n, err := watcher.Next()
if err != nil {
return "", err
}
if n.ErrMessage != nil {
msg := *n.ErrMessage
return "", fmt.Errorf("backend error: %v", msg)
}
if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
return *url, nil
}
}
return fmt.Sprintf("http://%s", net.JoinHostPort(cmpx.Or(host, "127.0.0.1"), port))
}

View File

@@ -4,7 +4,6 @@
package cli
import (
"net/url"
"testing"
)
@@ -44,58 +43,3 @@ func TestUrlOfListenAddr(t *testing.T) {
})
}
}
func TestQnapAuthnURL(t *testing.T) {
query := url.Values{
"qtoken": []string{"token"},
}
tests := []struct {
name string
in string
want string
}{
{
name: "localhost http",
in: "http://localhost:8088/",
want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "localhost https",
in: "https://localhost:5000/",
want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "IP http",
in: "http://10.1.20.4:80/",
want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "IP6 https",
in: "https://[ff7d:0:1:2::1]/",
want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "hostname https",
in: "https://qnap.example.com/",
want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "invalid URL",
in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.",
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "err != nil",
in: "http://192.168.0.%31/",
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u := qnapAuthnURL(tt.in, query)
if u != tt.want {
t.Errorf("expected url: %q, got: %q", tt.want, u)
}
})
}
}

View File

@@ -10,8 +10,17 @@ 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/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil/authenticode
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/util/quarantine+
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
@@ -23,7 +32,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
💣 github.com/mattn/go-isatty from github.com/mattn/go-colorable+
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
github.com/miekg/dns from tailscale.com/net/dns/recursive
💣 github.com/mitchellh/go-ps from tailscale.com/cmd/tailscale/cli+
github.com/peterbourgon/ff/v3 from github.com/peterbourgon/ff/v3/ffcli
github.com/peterbourgon/ff/v3/ffcli from tailscale.com/cmd/tailscale/cli
@@ -36,11 +47,14 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/derp+
go4.org/netipx from tailscale.com/wgengine/filter
go4.org/netipx from tailscale.com/wgengine/filter+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
gopkg.in/yaml.v2 from sigs.k8s.io/yaml
k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli
@@ -54,7 +68,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+
tailscale.com/client/tailscale/apitype from tailscale.com/cmd/tailscale/cli+
💣 tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/client/web from tailscale.com/cmd/tailscale/cli
tailscale.com/clientupdate from tailscale.com/cmd/tailscale/cli
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/control/controlbase from tailscale.com/control/controlhttp
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
tailscale.com/control/controlknobs from tailscale.com/net/portmapper
@@ -67,7 +83,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/hostinfo from tailscale.com/net/interfaces+
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+
tailscale.com/ipn/ipnstate from tailscale.com/cmd/tailscale/cli+
tailscale.com/licenses from tailscale.com/cmd/tailscale/cli+
tailscale.com/metrics from tailscale.com/derp
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
tailscale.com/net/dnscache from tailscale.com/derp/derphttp+
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp
tailscale.com/net/flowtrack from tailscale.com/wgengine/filter+
@@ -84,6 +102,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
tailscale.com/net/sockstats from tailscale.com/control/controlhttp+
tailscale.com/net/stun from tailscale.com/net/netcheck
L tailscale.com/net/tcpinfo from tailscale.com/derp
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp+
tailscale.com/net/tsaddr from tailscale.com/net/interfaces+
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
@@ -94,7 +113,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/tailcfg from tailscale.com/cmd/tailscale/cli+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime from tailscale.com/control/controlhttp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/empty from tailscale.com/ipn
@@ -114,19 +134,23 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/clientmetric from tailscale.com/net/netcheck+
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/cmpx from tailscale.com/cmd/tailscale/cli+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/groupmember from tailscale.com/cmd/tailscale/cli
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/groupmember from tailscale.com/client/web
tailscale.com/util/httpm from tailscale.com/client/tailscale+
tailscale.com/util/lineread from tailscale.com/net/interfaces+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
tailscale.com/util/mak from tailscale.com/net/netcheck+
tailscale.com/util/multierr from tailscale.com/control/controlhttp+
tailscale.com/util/must from tailscale.com/cmd/tailscale/cli
tailscale.com/util/must from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli
tailscale.com/util/set from tailscale.com/health+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
tailscale.com/version from tailscale.com/cmd/tailscale/cli+
tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+
tailscale.com/wgengine/capture from tailscale.com/cmd/tailscale/cli
@@ -144,7 +168,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/exp/constraints from golang.org/x/exp/slices
golang.org/x/exp/constraints from golang.org/x/exp/slices+
golang.org/x/exp/maps from tailscale.com/types/views+
golang.org/x/exp/slices from tailscale.com/net/tsaddr+
golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
@@ -176,7 +201,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
bytes from bufio+
compress/flate from compress/gzip+
compress/gzip from net/http
compress/zlib from image/png
compress/zlib from image/png+
container/list from crypto/tls+
context from crypto/tls+
crypto from crypto/ecdsa+
@@ -201,16 +226,18 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from tailscale.com/cmd/tailscale/cli+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from tailscale.com/tka
encoding/base32 from tailscale.com/tka+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
encoding/xml from tailscale.com/cmd/tailscale/cli+
encoding/xml from github.com/tailscale/goupnp+
errors from bufio+
expvar from tailscale.com/derp+
flag from github.com/peterbourgon/ff/v3+
@@ -220,7 +247,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
hash/crc32 from compress/gzip+
hash/maphash from go4.org/mem
html from tailscale.com/ipn/ipnstate+
html/template from tailscale.com/cmd/tailscale/cli
html/template from tailscale.com/client/web
image from github.com/skip2/go-qrcode+
image/color from github.com/skip2/go-qrcode+
image/png from github.com/skip2/go-qrcode
@@ -228,6 +255,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
io/fs from crypto/x509+
io/ioutil from golang.org/x/sys/cpu+
log from expvar+
log/internal from log
math from compress/flate+
math/big from crypto/dsa+
math/bits from compress/flate+
@@ -239,7 +267,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
net/http from expvar+
net/http/cgi from tailscale.com/cmd/tailscale/cli
net/http/httptrace from github.com/tcnksm/go-httpstat+
net/http/httputil from tailscale.com/cmd/tailscale/cli
net/http/httputil from tailscale.com/cmd/tailscale/cli+
net/http/internal from net/http+
net/netip from net+
net/textproto from golang.org/x/net/http/httpguts+

View File

@@ -75,17 +75,24 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws/middleware+
L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
L github.com/coreos/go-iptables/iptables from tailscale.com/wgengine/router
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com+
W 💣 github.com/dblohm7/wingoes/com from tailscale.com/cmd/tailscaled
W github.com/dblohm7/wingoes/internal from github.com/dblohm7/wingoes/com
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/osdiag+
github.com/fxamacker/cbor/v2 from tailscale.com/tka
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header+
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
@@ -109,8 +116,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/mdlayher/genetlink from tailscale.com/net/tstun
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L github.com/mdlayher/sdnotify from tailscale.com/util/systemd
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
github.com/miekg/dns from tailscale.com/net/dns/recursive
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
L github.com/pierrec/lz4/v4 from github.com/u-root/uio/uio
L github.com/pierrec/lz4/v4/internal/lz4block from github.com/pierrec/lz4/v4+
@@ -121,6 +130,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
github.com/tailscale/golang-x-crypto/acme from tailscale.com/ipn/ipnlocal
LD github.com/tailscale/golang-x-crypto/chacha20 from github.com/tailscale/golang-x-crypto/ssh
LD 💣 github.com/tailscale/golang-x-crypto/internal/alias from github.com/tailscale/golang-x-crypto/chacha20
LD github.com/tailscale/golang-x-crypto/ssh from tailscale.com/ipn/ipnlocal+
@@ -242,6 +252,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/connstats from tailscale.com/net/tstun+
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver+
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
tailscale.com/net/dns/resolver from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dnscache from tailscale.com/control/controlclient+
@@ -264,6 +275,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/net/netcheck+
L tailscale.com/net/tcpinfo from tailscale.com/derp
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
tailscale.com/net/tsaddr from tailscale.com/ipn+
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
@@ -280,11 +292,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
tailscale.com/tka from tailscale.com/ipn/ipnlocal+
W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tsd from tailscale.com/cmd/tailscaled+
tailscale.com/tstime from tailscale.com/wgengine/magicsock+
💣 tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
@@ -308,6 +321,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/clientmetric from tailscale.com/control/controlclient+
tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
tailscale.com/util/cmpx from tailscale.com/derp/derphttp+
💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+
tailscale.com/util/dnsname from tailscale.com/hostinfo+
@@ -316,9 +330,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale+
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns+
tailscale.com/util/mak from tailscale.com/control/controlclient+
tailscale.com/util/multierr from tailscale.com/control/controlclient+
tailscale.com/util/must from tailscale.com/logpolicy
💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
W tailscale.com/util/pidowner from tailscale.com/ipn/ipnauth
tailscale.com/util/racebuild from tailscale.com/logpolicy
@@ -328,8 +344,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock+
💣 tailscale.com/util/winutil from tailscale.com/control/controlclient+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/util/osdiag
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+
@@ -346,7 +364,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine
tailscale.com/wgengine/wglog from tailscale.com/wgengine
W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router
golang.org/x/crypto/acme from tailscale.com/ipn/ipnlocal
golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
@@ -364,7 +381,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+
LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh+
golang.org/x/exp/constraints from golang.org/x/exp/slices+
golang.org/x/exp/maps from tailscale.com/wgengine
golang.org/x/exp/maps from tailscale.com/wgengine+
golang.org/x/exp/slices from tailscale.com/ipn/ipnlocal+
golang.org/x/net/bpf from github.com/mdlayher/genetlink+
golang.org/x/net/dns/dnsmessage from net+
@@ -395,8 +412,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/time/rate from gvisor.dev/gvisor/pkg/tcpip/stack+
bufio from compress/flate+
bytes from bufio+
cmp from slices
compress/flate from compress/gzip+
compress/gzip from golang.org/x/net/http2+
W compress/zlib from debug/pe
container/heap from gvisor.dev/gvisor/pkg/tcpip/transport/tcp
container/list from crypto/tls+
context from crypto/tls+
@@ -421,10 +440,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
crypto/tls from github.com/tcnksm/go-httpstat+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from tailscale.com+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from tailscale.com/tka
encoding/base32 from tailscale.com/tka+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/hex from crypto/x509+
@@ -436,7 +457,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
flag from net/http/httptest+
fmt from compress/flate+
hash from crypto+
hash/adler32 from tailscale.com/ipn/ipnlocal
hash/adler32 from tailscale.com/ipn/ipnlocal+
hash/crc32 from compress/gzip+
hash/fnv from tailscale.com/wgengine/magicsock+
hash/maphash from go4.org/mem
@@ -445,6 +466,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
io/fs from crypto/x509+
io/ioutil from github.com/godbus/dbus/v5+
log from expvar+
log/internal from log
LD log/syslog from tailscale.com/ssh/tailssh
math from compress/flate+
math/big from crypto/dsa+
@@ -475,6 +497,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
runtime/debug from github.com/klauspost/compress/zstd+
runtime/pprof from tailscale.com/log/logheap+
runtime/trace from net/http/pprof
slices from tailscale.com/wgengine/magicsock
sort from compress/flate+
strconv from compress/flate+
strings from bufio+

View File

@@ -1,10 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !go1.20
//go:build !go1.21
package main
func init() {
you_need_Go_1_20_to_compile_Tailscale()
you_need_Go_1_21_to_compile_Tailscale()
}

View File

@@ -342,7 +342,7 @@ func run() error {
}
sys.Set(netMon)
pol := logpolicy.New(logtail.CollectionNode, netMon)
pol := logpolicy.New(logtail.CollectionNode, netMon, nil /* use log.Printf */)
pol.SetVerbosityLevel(args.verbose)
logPol = pol
defer func() {

View File

@@ -3,10 +3,32 @@
package main // import "tailscale.com/cmd/tailscaled"
import "testing"
import (
"testing"
"tailscale.com/tstest/deptest"
)
func TestNothing(t *testing.T) {
// This test does nothing on purpose, so we can run
// GODEBUG=memprofilerate=1 go test -v -run=Nothing -memprofile=prof.mem
// without any errors about no matching tests.
}
func TestDeps(t *testing.T) {
deptest.DepChecker{
GOOS: "darwin",
GOARCH: "arm64",
BadDeps: map[string]string{
"gvisor.dev/gvisor/pkg/hostarch": "will crash on non-4K page sizes; see https://github.com/tailscale/tailscale/issues/8658",
},
}.Check(t)
deptest.DepChecker{
GOOS: "linux",
GOARCH: "arm64",
BadDeps: map[string]string{
"gvisor.dev/gvisor/pkg/hostarch": "will crash on non-4K page sizes; see https://github.com/tailscale/tailscale/issues/8658",
},
}.Check(t)
}

View File

@@ -50,6 +50,7 @@ import (
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/util/osdiag"
"tailscale.com/util/winutil"
"tailscale.com/version"
"tailscale.com/wf"
@@ -127,7 +128,7 @@ var syslogf logger.Logf = logger.Discard
// Windows started.
func runWindowsService(pol *logpolicy.Policy) error {
go func() {
winutil.LogSupportInfo(log.Printf)
osdiag.LogSupportInfo(logger.WithPrefix(log.Printf, "Support Info: "), osdiag.LogSupportInfoReasonStartup)
}()
if winutil.GetPolicyInteger("LogSCMInteractions", 0) != 0 {

View File

@@ -7,16 +7,20 @@
package flakytest
import (
"fmt"
"os"
"regexp"
"testing"
)
// InTestWrapper returns whether or not this binary is running under our test
// wrapper.
func InTestWrapper() bool {
return os.Getenv("TS_IN_TESTWRAPPER") != ""
}
// FlakyTestLogMessage is a sentinel value that is printed to stderr when a
// flaky test is marked. This is used by cmd/testwrapper to detect flaky tests
// and retry them.
const FlakyTestLogMessage = "flakytest: this is a known flaky test"
// FlakeAttemptEnv is an environment variable that is set by cmd/testwrapper
// when a flaky test is retried. It contains the attempt number, starting at 1.
const FlakeAttemptEnv = "TS_TESTWRAPPER_ATTEMPT"
var issueRegexp = regexp.MustCompile(`\Ahttps://github\.com/tailscale/[a-zA-Z0-9_.-]+/issues/\d+\z`)
@@ -30,16 +34,6 @@ func Mark(t testing.TB, issue string) {
t.Fatalf("bad issue format: %q", issue)
}
if !InTestWrapper() {
return
}
t.Cleanup(func() {
if t.Failed() {
t.Logf("flakytest: signaling test wrapper to retry test")
// Signal to test wrapper that we should restart.
os.Exit(123)
}
})
fmt.Fprintln(os.Stderr, FlakyTestLogMessage) // sentinel value for testwrapper
t.Logf("flakytest: issue tracking this flaky test: %s", issue)
}

View File

@@ -3,7 +3,10 @@
package flakytest
import "testing"
import (
"os"
"testing"
)
func TestIssueFormat(t *testing.T) {
testCases := []struct {
@@ -24,3 +27,17 @@ func TestIssueFormat(t *testing.T) {
}
}
}
// TestFlakeRun is a test that fails when run in the testwrapper
// for the first time, but succeeds on the second run.
// It's used to test whether the testwrapper retries flaky tests.
func TestFlakeRun(t *testing.T) {
Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue
e := os.Getenv(FlakeAttemptEnv)
if e == "" {
t.Skip("not running in testwrapper")
}
if e == "1" {
t.Fatal("First run in testwrapper, failing so that test is retried. This is expected.")
}
}

View File

@@ -1,62 +1,288 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// testwrapper is a wrapper for retrying flaky tests, using the -exec flag of
// 'go test'. Tests that are flaky can use the 'flakytest' subpackage to mark
// themselves as flaky and be retried on failure.
// testwrapper is a wrapper for retrying flaky tests. It is an alternative to
// `go test` and re-runs failed marked flaky tests (using the flakytest pkg). It
// takes different arguments than go test and requires the first positional
// argument to be the pattern to test.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"sort"
"strings"
"time"
"golang.org/x/exp/maps"
"tailscale.com/cmd/testwrapper/flakytest"
)
const (
retryStatus = 123
maxIterations = 3
)
const maxAttempts = 3
type testAttempt struct {
name testName
outcome string // "pass", "fail", "skip"
logs bytes.Buffer
isMarkedFlaky bool // set if the test is marked as flaky
pkgFinished bool
}
type testName struct {
pkg string // "tailscale.com/types/key"
name string // "TestFoo"
}
type packageTests struct {
// pattern is the package pattern to run.
// Must be a single pattern, not a list of patterns.
pattern string // "./...", "./types/key"
// tests is a list of tests to run. If empty, all tests in the package are
// run.
tests []string // ["TestFoo", "TestBar"]
}
type goTestOutput struct {
Time time.Time
Action string
Package string
Test string
Output string
}
var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
// runTests runs the tests in pt and sends the results on ch. It sends a
// testAttempt for each test and a final testAttempt per pkg with pkgFinished
// set to true.
// It calls close(ch) when it's done.
func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []string, ch chan<- *testAttempt) {
defer close(ch)
args := []string{"test", "-json", pt.pattern}
args = append(args, otherArgs...)
if len(pt.tests) > 0 {
runArg := strings.Join(pt.tests, "|")
args = append(args, "-run", runArg)
}
if debug {
fmt.Println("running", strings.Join(args, " "))
}
cmd := exec.CommandContext(ctx, "go", args...)
r, err := cmd.StdoutPipe()
if err != nil {
log.Printf("error creating stdout pipe: %v", err)
}
defer r.Close()
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", flakytest.FlakeAttemptEnv, attempt))
if err := cmd.Start(); err != nil {
log.Printf("error starting test: %v", err)
os.Exit(1)
}
done := make(chan struct{})
go func() {
defer close(done)
cmd.Wait()
}()
jd := json.NewDecoder(r)
resultMap := make(map[testName]*testAttempt)
for {
var goOutput goTestOutput
if err := jd.Decode(&goOutput); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
break
}
// `go test -json` outputs invalid JSON when a build fails.
// In that case, discard the the output and start reading again.
// The build error will be printed to stderr.
// See: https://github.com/golang/go/issues/35169
if _, ok := err.(*json.SyntaxError); ok {
jd = json.NewDecoder(r)
continue
}
panic(err)
}
if goOutput.Test == "" {
switch goOutput.Action {
case "fail", "pass", "skip":
ch <- &testAttempt{
name: testName{
pkg: goOutput.Package,
},
outcome: goOutput.Action,
pkgFinished: true,
}
}
continue
}
name := testName{
pkg: goOutput.Package,
name: goOutput.Test,
}
if test, _, isSubtest := strings.Cut(goOutput.Test, "/"); isSubtest {
name.name = test
if goOutput.Action == "output" {
resultMap[name].logs.WriteString(goOutput.Output)
}
continue
}
switch goOutput.Action {
case "start":
// ignore
case "run":
resultMap[name] = &testAttempt{
name: name,
}
case "skip", "pass", "fail":
resultMap[name].outcome = goOutput.Action
ch <- resultMap[name]
case "output":
if strings.TrimSpace(goOutput.Output) == flakytest.FlakyTestLogMessage {
resultMap[name].isMarkedFlaky = true
} else {
resultMap[name].logs.WriteString(goOutput.Output)
}
}
}
<-done
}
func main() {
ctx := context.Background()
debug := os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
log.SetPrefix("testwrapper: ")
if !debug {
log.SetFlags(0)
// We only need to parse the -v flag to figure out whether to print the logs
// for a test. We don't need to parse any other flags, so we just use the
// flag package to parse the -v flag and then pass the rest of the args
// through to 'go test'.
// We run `go test -json` which returns the same information as `go test -v`,
// but in a machine-readable format. So this flag is only for testwrapper's
// output.
v := flag.Bool("v", false, "verbose")
flag.Usage = func() {
fmt.Println("usage: testwrapper [testwrapper-flags] [pattern] [build/test flags & test binary flags]")
fmt.Println()
fmt.Println("testwrapper-flags:")
flag.CommandLine.PrintDefaults()
fmt.Println()
fmt.Println("examples:")
fmt.Println("\ttestwrapper -v ./... -count=1")
fmt.Println("\ttestwrapper ./pkg/foo -run TestBar -count=1")
fmt.Println()
fmt.Println("Unlike 'go test', testwrapper requires a package pattern as the first positional argument and only supports a single pattern.")
}
flag.Parse()
args := flag.Args()
if len(args) < 1 || strings.HasPrefix(args[0], "-") {
fmt.Println("no pattern specified")
flag.Usage()
os.Exit(1)
} else if len(args) > 1 && !strings.HasPrefix(args[1], "-") {
fmt.Println("expected single pattern")
flag.Usage()
os.Exit(1)
}
pattern, otherArgs := args[0], args[1:]
type nextRun struct {
tests []*packageTests
attempt int
}
for i := 1; i <= maxIterations; i++ {
if i > 1 {
log.Printf("retrying flaky tests (%d of %d)", i, maxIterations)
}
cmd := exec.CommandContext(ctx, os.Args[1], os.Args[2:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), "TS_IN_TESTWRAPPER=1")
err := cmd.Run()
if err == nil {
toRun := []*nextRun{
{
tests: []*packageTests{{pattern: pattern}},
attempt: 1,
},
}
printPkgOutcome := func(pkg, outcome string, attempt int) {
if outcome == "skip" {
fmt.Printf("?\t%s [skipped/no tests] \n", pkg)
return
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
if debug {
log.Printf("error isn't an ExitError")
}
os.Exit(1)
if outcome == "pass" {
outcome = "ok"
}
if code := exitErr.ExitCode(); code != retryStatus {
if debug {
log.Printf("code (%d) != retryStatus (%d)", code, retryStatus)
}
os.Exit(code)
if outcome == "fail" {
outcome = "FAIL"
}
if attempt > 1 {
fmt.Printf("%s\t%s [attempt=%d]\n", outcome, pkg, attempt)
return
}
fmt.Printf("%s\t%s\n", outcome, pkg)
}
log.Printf("test did not pass in %d iterations", maxIterations)
os.Exit(1)
for len(toRun) > 0 {
var thisRun *nextRun
thisRun, toRun = toRun[0], toRun[1:]
if thisRun.attempt >= maxAttempts {
fmt.Println("max attempts reached")
os.Exit(1)
}
if thisRun.attempt > 1 {
fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\n", thisRun.attempt)
}
failed := false
toRetry := make(map[string][]string) // pkg -> tests to retry
for _, pt := range thisRun.tests {
ch := make(chan *testAttempt)
go runTests(ctx, thisRun.attempt, pt, otherArgs, ch)
for tr := range ch {
if tr.pkgFinished {
printPkgOutcome(tr.name.pkg, tr.outcome, thisRun.attempt)
continue
}
if *v || tr.outcome == "fail" {
io.Copy(os.Stdout, &tr.logs)
}
if tr.outcome != "fail" {
continue
}
if tr.isMarkedFlaky {
toRetry[tr.name.pkg] = append(toRetry[tr.name.pkg], tr.name.name)
} else {
failed = true
}
}
}
if failed {
fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
os.Exit(1)
}
if len(toRetry) == 0 {
continue
}
pkgs := maps.Keys(toRetry)
sort.Strings(pkgs)
nextRun := &nextRun{
attempt: thisRun.attempt + 1,
}
for _, pkg := range pkgs {
tests := toRetry[pkg]
sort.Strings(tests)
nextRun.tests = append(nextRun.tests, &packageTests{
pattern: pkg,
tests: tests,
})
}
toRun = append(toRun, nextRun)
}
}

View File

@@ -32,6 +32,9 @@ func commonSetup(dev bool) (*esbuild.BuildOptions, error) {
if err != nil {
return nil, err
}
if *yarnPath == "" {
*yarnPath = path.Join(root, "tool", "yarn")
}
tsConnectDir := filepath.Join(root, "cmd", "tsconnect")
if err := os.Chdir(tsConnectDir); err != nil {
return nil, fmt.Errorf("Cannot change cwd: %w", err)

View File

@@ -20,7 +20,7 @@ var (
addr = flag.String("addr", ":9090", "address to listen on")
distDir = flag.String("distdir", "./dist", "path of directory to place build output in")
pkgDir = flag.String("pkgdir", "./pkg", "path of directory to place NPM package build output in")
yarnPath = flag.String("yarnpath", "../../tool/yarn", "path yarn executable used to install JavaScript dependencies")
yarnPath = flag.String("yarnpath", "", "path yarn executable used to install JavaScript dependencies")
fastCompression = flag.Bool("fast-compression", false, "Use faster compression when building, to speed up build time. Meant to iterative/debugging use only.")
devControl = flag.String("dev-control", "", "URL of a development control server to be used with dev. If provided without specifying dev, an error will be returned.")
rootDir = flag.String("rootdir", "", "Root directory of repo. If not specified, will be inferred from the cwd.")

View File

@@ -9,17 +9,20 @@ import (
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"tailscale.com/health"
"tailscale.com/logtail/backoff"
"tailscale.com/net/sockstats"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/empty"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/ptr"
"tailscale.com/types/structs"
)
@@ -44,14 +47,103 @@ func (g *LoginGoal) sendLogoutError(err error) {
var _ Client = (*Auto)(nil)
// waitUnpause waits until the client is unpaused then returns. It only
// returns an error if the client is closed.
func (c *Auto) waitUnpause(routineLogName string) error {
c.mu.Lock()
if !c.paused {
c.mu.Unlock()
return nil
}
unpaused := c.unpausedChanLocked()
c.mu.Unlock()
c.logf("%s: awaiting unpause", routineLogName)
select {
case <-unpaused:
c.logf("%s: unpaused", routineLogName)
return nil
case <-c.quit:
return errors.New("quit")
}
}
// updateRoutine is responsible for informing the server of worthy changes to
// our local state. It runs in its own goroutine.
func (c *Auto) updateRoutine() {
defer close(c.updateDone)
bo := backoff.NewBackoff("updateRoutine", c.logf, 30*time.Second)
// lastUpdateGenInformed is the value of lastUpdateAt that we've successfully
// informed the server of.
var lastUpdateGenInformed updateGen
for {
if err := c.waitUnpause("updateRoutine"); err != nil {
c.logf("updateRoutine: exiting")
return
}
c.mu.Lock()
gen := c.lastUpdateGen
ctx := c.mapCtx
needUpdate := gen > 0 && gen != lastUpdateGenInformed && c.loggedIn
c.mu.Unlock()
if needUpdate {
select {
case <-c.quit:
c.logf("updateRoutine: exiting")
return
default:
}
} else {
// Nothing to do, wait for a signal.
select {
case <-c.quit:
c.logf("updateRoutine: exiting")
return
case <-c.updateCh:
continue
}
}
t0 := c.clock.Now()
err := c.direct.SendUpdate(ctx)
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
if ctx.Err() == nil {
c.direct.logf("lite map update error after %v: %v", d, err)
}
bo.BackOff(ctx, err)
continue
}
bo.BackOff(ctx, nil)
c.direct.logf("[v1] successful lite map update in %v", d)
lastUpdateGenInformed = gen
}
}
// atomicGen is an atomic int64 generator. It is used to generate monotonically
// increasing numbers for updateGen.
var atomicGen atomic.Int64
func nextUpdateGen() updateGen {
return updateGen(atomicGen.Add(1))
}
// updateGen is a monotonically increasing number that represents a particular
// update to the local state.
type updateGen int64
// Auto connects to a tailcontrol server for a node.
// It's a concrete implementation of the Client interface.
type Auto struct {
direct *Direct // our interface to the server APIs
timeNow func() time.Time
clock tstime.Clock
logf logger.Logf
expiry *time.Time
closed bool
updateCh chan struct{} // readable when we should inform the server of a change
newMapCh chan struct{} // readable when we must restart a map request
statusFunc func(Status) // called to update Client status; always non-nil
@@ -59,25 +151,26 @@ type Auto struct {
mu sync.Mutex // mutex guards the following fields
paused bool // whether we should stop making HTTP requests
unpauseWaiters []chan struct{}
loggedIn bool // true if currently logged in
loginGoal *LoginGoal // non-nil if some login activity is desired
synced bool // true if our netmap is up-to-date
inPollNetMap bool // true if currently running a PollNetMap
inLiteMapUpdate bool // true if a lite (non-streaming) map request is outstanding
liteMapUpdateCancel context.CancelFunc // cancels a lite map update, may be nil
liteMapUpdateCancels int // how many times we've canceled a lite map update
inSendStatus int // number of sendStatus calls currently in progress
state State
// lastUpdateGen is the gen of last update we had an update worth sending to
// the server.
lastUpdateGen updateGen
paused bool // whether we should stop making HTTP requests
unpauseWaiters []chan struct{}
loggedIn bool // true if currently logged in
loginGoal *LoginGoal // non-nil if some login activity is desired
synced bool // true if our netmap is up-to-date
inSendStatus int // number of sendStatus calls currently in progress
state State
authCtx context.Context // context used for auth requests
mapCtx context.Context // context used for netmap requests
authCancel func() // cancel the auth context
mapCancel func() // cancel the netmap context
mapCtx context.Context // context used for netmap and update requests
authCancel func() // cancel authCtx
mapCancel func() // cancel mapCtx
quit chan struct{} // when closed, goroutines should all exit
authDone chan struct{} // when closed, auth goroutine is done
mapDone chan struct{} // when closed, map goroutine is done
authDone chan struct{} // when closed, authRoutine is done
mapDone chan struct{} // when closed, mapRoutine is done
updateDone chan struct{} // when closed, updateRoutine is done
}
// New creates and starts a new Auto.
@@ -107,17 +200,19 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
if opts.Logf == nil {
opts.Logf = func(fmt string, args ...any) {}
}
if opts.TimeNow == nil {
opts.TimeNow = time.Now
if opts.Clock == nil {
opts.Clock = tstime.StdClock{}
}
c := &Auto{
direct: direct,
timeNow: opts.TimeNow,
clock: opts.Clock,
logf: opts.Logf,
updateCh: make(chan struct{}, 1),
newMapCh: make(chan struct{}, 1),
quit: make(chan struct{}),
authDone: make(chan struct{}),
mapDone: make(chan struct{}),
updateDone: make(chan struct{}),
statusFunc: opts.Status,
}
c.authCtx, c.authCancel = context.WithCancel(context.Background())
@@ -160,85 +255,34 @@ func (c *Auto) SetPaused(paused bool) {
func (c *Auto) Start() {
go c.authRoutine()
go c.mapRoutine()
go c.updateRoutine()
}
// sendNewMapRequest either sends a new OmitPeers, non-streaming map request
// (to just send Hostinfo/Netinfo/Endpoints info, while keeping an existing
// streaming response open), or start a new streaming one if necessary.
// updateControl sends a new OmitPeers, non-streaming map request (to just send
// Hostinfo/Netinfo/Endpoints info, while keeping an existing streaming response
// open).
//
// It should be called whenever there's something new to tell the server.
func (c *Auto) sendNewMapRequest() {
func (c *Auto) updateControl() {
gen := nextUpdateGen()
c.mu.Lock()
// If we're not already streaming a netmap, then tear down everything
// and start a new stream (which starts by sending a new map request)
if !c.inPollNetMap || !c.loggedIn {
if gen < c.lastUpdateGen {
// This update is out of date.
c.mu.Unlock()
c.cancelMapSafely()
return
}
c.lastUpdateGen = gen
c.mu.Unlock()
// If we are already in process of doing a LiteMapUpdate, cancel it and
// try a new one. If this is the 10th time we have done this
// cancelation, tear down everything and start again.
const maxLiteMapUpdateAttempts = 10
if c.inLiteMapUpdate {
// Always cancel the in-flight lite map update, regardless of
// whether we cancel the streaming map request or not.
c.liteMapUpdateCancel()
c.inLiteMapUpdate = false
if c.liteMapUpdateCancels >= maxLiteMapUpdateAttempts {
// Not making progress
c.mu.Unlock()
c.cancelMapSafely()
return
}
// Increment our cancel counter and continue below to start a
// new lite update.
c.liteMapUpdateCancels++
select {
case c.updateCh <- struct{}{}:
default:
}
// Otherwise, send a lite update that doesn't keep a
// long-running stream response.
defer c.mu.Unlock()
c.inLiteMapUpdate = true
ctx, cancel := context.WithTimeout(c.mapCtx, 10*time.Second)
c.liteMapUpdateCancel = cancel
go func() {
defer cancel()
t0 := time.Now()
err := c.direct.SendLiteMapUpdate(ctx)
d := time.Since(t0).Round(time.Millisecond)
c.mu.Lock()
c.inLiteMapUpdate = false
c.liteMapUpdateCancel = nil
if err == nil {
c.liteMapUpdateCancels = 0
}
c.mu.Unlock()
if err == nil {
c.logf("[v1] successful lite map update in %v", d)
return
}
if ctx.Err() == nil {
c.logf("lite map update after %v: %v", d, err)
}
if !errors.Is(ctx.Err(), context.Canceled) {
// Fall back to restarting the long-polling map
// request (the old heavy way) if the lite update
// failed for reasons other than the context being
// canceled.
c.cancelMapSafely()
}
}()
}
func (c *Auto) cancelAuth() {
c.mu.Lock()
defer c.mu.Unlock()
if c.authCancel != nil {
c.authCancel()
}
@@ -246,9 +290,9 @@ func (c *Auto) cancelAuth() {
c.authCtx, c.authCancel = context.WithCancel(context.Background())
c.authCtx = sockstats.WithSockStats(c.authCtx, sockstats.LabelControlClientAuto, c.logf)
}
c.mu.Unlock()
}
// cancelMapLocked is like cancelMap, but assumes the caller holds c.mu.
func (c *Auto) cancelMapLocked() {
if c.mapCancel != nil {
c.mapCancel()
@@ -256,56 +300,36 @@ func (c *Auto) cancelMapLocked() {
if !c.closed {
c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
c.mapCtx = sockstats.WithSockStats(c.mapCtx, sockstats.LabelControlClientAuto, c.logf)
}
}
func (c *Auto) cancelMapUnsafely() {
c.mu.Lock()
c.cancelMapLocked()
c.mu.Unlock()
}
func (c *Auto) cancelMapSafely() {
// cancelMap cancels the existing mapPoll and liteUpdates.
func (c *Auto) cancelMap() {
c.mu.Lock()
defer c.mu.Unlock()
c.cancelMapLocked()
}
// Always reset our lite map cancels counter if we're canceling
// everything, since we're about to restart with a new map update; this
// allows future calls to sendNewMapRequest to retry sending lite
// updates.
c.liteMapUpdateCancels = 0
// restartMap cancels the existing mapPoll and liteUpdates, and then starts a
// new one.
func (c *Auto) restartMap() {
c.mu.Lock()
c.cancelMapLocked()
synced := c.synced
c.mu.Unlock()
c.logf("[v1] cancelMapSafely: synced=%v", c.synced)
c.logf("[v1] restartMap: synced=%v", synced)
if c.inPollNetMap {
// received at least one netmap since the last
// interruption. That means the server has already
// fully processed our last request, which might
// include UpdateEndpoints(). Interrupt it and try
// again.
c.cancelMapLocked()
} else {
// !synced means we either haven't done a netmap
// request yet, or it hasn't answered yet. So the
// server is in an undefined state. If we send
// another netmap request too soon, it might race
// with the last one, and if we're very unlucky,
// the new request will be applied before the old one,
// and the wrong endpoints will get registered. We
// have to tell the client to abort politely, only
// after it receives a response to its existing netmap
// request.
select {
case c.newMapCh <- struct{}{}:
c.logf("[v1] cancelMapSafely: wrote to channel")
default:
// if channel write failed, then there was already
// an outstanding newMapCh request. One is enough,
// since it'll always use the latest endpoints.
c.logf("[v1] cancelMapSafely: channel was full")
}
select {
case c.newMapCh <- struct{}{}:
c.logf("[v1] restartMap: wrote to channel")
default:
// if channel write failed, then there was already
// an outstanding newMapCh request. One is enough,
// since it'll always use the latest endpoints.
c.logf("[v1] restartMap: channel was full")
}
c.updateControl()
}
func (c *Auto) authRoutine() {
@@ -426,7 +450,7 @@ func (c *Auto) authRoutine() {
c.mu.Unlock()
c.sendStatus("authRoutine-success", nil, "", nil)
c.cancelMapSafely()
c.restartMap()
bo.BackOff(ctx, nil)
}
}
@@ -456,25 +480,50 @@ func (c *Auto) unpausedChanLocked() <-chan struct{} {
return unpaused
}
// mapRoutineState is the state of Auto.mapRoutine while it's running.
type mapRoutineState struct {
c *Auto
bo *backoff.Backoff
}
func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
c := mrs.c
health.SetInPollNetMap(true)
c.mu.Lock()
ctx := c.mapCtx
c.synced = true
if c.loggedIn {
c.state = StateSynchronized
}
c.expiry = ptr.To(nm.Expiry)
stillAuthed := c.loggedIn
c.logf("[v1] mapRoutine: netmap received: %s", c.state)
c.mu.Unlock()
if stillAuthed {
c.sendStatus("mapRoutine-got-netmap", nil, "", nm)
}
// Reset the backoff timer if we got a netmap.
mrs.bo.BackOff(ctx, nil)
}
// mapRoutine is responsible for keeping a read-only streaming connection to the
// control server, and keeping the netmap up to date.
func (c *Auto) mapRoutine() {
defer close(c.mapDone)
bo := backoff.NewBackoff("mapRoutine", c.logf, 30*time.Second)
mrs := &mapRoutineState{
c: c,
bo: backoff.NewBackoff("mapRoutine", c.logf, 30*time.Second),
}
for {
c.mu.Lock()
if c.paused {
unpaused := c.unpausedChanLocked()
c.mu.Unlock()
c.logf("mapRoutine: awaiting unpause")
select {
case <-unpaused:
c.logf("mapRoutine: unpaused")
case <-c.quit:
c.logf("mapRoutine: quit")
return
}
continue
if err := c.waitUnpause("mapRoutine"); err != nil {
c.logf("mapRoutine: exiting")
return
}
c.mu.Lock()
c.logf("[v1] mapRoutine: %s", c.state)
loggedIn := c.loggedIn
ctx := c.mapCtx
@@ -511,52 +560,13 @@ func (c *Auto) mapRoutine() {
c.logf("[v1] mapRoutine: new map needed while idle.")
}
} else {
// Be sure this is false when we're not inside
// PollNetMap, so that cancelMapSafely() can notify
// us correctly.
c.mu.Lock()
c.inPollNetMap = false
c.mu.Unlock()
health.SetInPollNetMap(false)
err := c.direct.PollNetMap(ctx, func(nm *netmap.NetworkMap) {
health.SetInPollNetMap(true)
c.mu.Lock()
select {
case <-c.newMapCh:
c.logf("[v1] mapRoutine: new map request during PollNetMap. canceling.")
c.cancelMapLocked()
// Don't emit this netmap; we're
// about to request a fresh one.
c.mu.Unlock()
return
default:
}
c.synced = true
c.inPollNetMap = true
if c.loggedIn {
c.state = StateSynchronized
}
exp := nm.Expiry
c.expiry = &exp
stillAuthed := c.loggedIn
state := c.state
c.mu.Unlock()
c.logf("[v1] mapRoutine: netmap received: %s", state)
if stillAuthed {
c.sendStatus("mapRoutine-got-netmap", nil, "", nm)
}
})
err := c.direct.PollNetMap(ctx, mrs)
health.SetInPollNetMap(false)
c.mu.Lock()
c.synced = false
c.inPollNetMap = false
if c.state == StateSynchronized {
c.state = StateAuthenticated
}
@@ -564,16 +574,14 @@ func (c *Auto) mapRoutine() {
c.mu.Unlock()
if paused {
mrs.bo.BackOff(ctx, nil)
c.logf("mapRoutine: paused")
continue
}
if err != nil {
report(err, "PollNetMap")
bo.BackOff(ctx, err)
continue
}
bo.BackOff(ctx, nil)
report(err, "PollNetMap")
mrs.bo.BackOff(ctx, err)
continue
}
}
}
@@ -598,7 +606,7 @@ func (c *Auto) SetHostinfo(hi *tailcfg.Hostinfo) {
}
// Send new Hostinfo to server
c.sendNewMapRequest()
c.updateControl()
}
func (c *Auto) SetNetInfo(ni *tailcfg.NetInfo) {
@@ -610,12 +618,17 @@ func (c *Auto) SetNetInfo(ni *tailcfg.NetInfo) {
}
// Send new NetInfo to server
c.sendNewMapRequest()
c.updateControl()
}
// SetTKAHead updates the TKA head hash that map-request infrastructure sends.
func (c *Auto) SetTKAHead(headHash string) {
c.direct.SetTKAHead(headHash)
if !c.direct.SetTKAHead(headHash) {
return
}
// Send new TKAHead to server
c.updateControl()
}
func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkMap) {
@@ -641,8 +654,7 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
logoutFin = new(empty.Message)
}
if nm != nil && loggedIn && synced {
pp := c.direct.GetPersist()
p = &pp
p = ptr.To(c.direct.GetPersist())
} else {
// don't send netmap status, as it's misleading when we're
// not logged in.
@@ -702,14 +714,14 @@ func (c *Auto) Logout(ctx context.Context) error {
c.mu.Unlock()
c.cancelAuth()
timer := time.NewTimer(10 * time.Second)
timer, timerChannel := c.clock.NewTimer(10 * time.Second)
defer timer.Stop()
select {
case err := <-errc:
return err
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
case <-timerChannel:
return context.DeadlineExceeded
}
}
@@ -725,7 +737,7 @@ func (c *Auto) SetExpirySooner(ctx context.Context, expiry time.Time) error {
func (c *Auto) UpdateEndpoints(endpoints []tailcfg.Endpoint) {
changed := c.direct.SetEndpoints(endpoints)
if changed {
c.sendNewMapRequest()
c.updateControl()
}
}
@@ -747,8 +759,9 @@ func (c *Auto) Shutdown() {
close(c.quit)
c.cancelAuth()
<-c.authDone
c.cancelMapUnsafely()
c.cancelMap()
<-c.mapDone
<-c.updateDone
if direct != nil {
direct.Close()
}
@@ -770,7 +783,7 @@ func (c *Auto) TestOnlySetAuthKey(authkey string) {
}
func (c *Auto) TestOnlyTimeNow() time.Time {
return c.timeNow()
return c.clock.Now()
}
// SetDNS sends the SetDNSRequest request to the control plane server,

View File

@@ -20,7 +20,7 @@ func dumpGoroutinesToURL(c *http.Client, targetURL string) {
zbuf := new(bytes.Buffer)
zw := gzip.NewWriter(zbuf)
zw.Write(goroutines.ScrubbedGoroutineDump())
zw.Write(goroutines.ScrubbedGoroutineDump(true))
zw.Close()
req, err := http.NewRequestWithContext(ctx, "PUT", targetURL, zbuf)

View File

@@ -45,11 +45,13 @@ import (
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/ptr"
"tailscale.com/types/tkatype"
"tailscale.com/util/clientmetric"
"tailscale.com/util/multierr"
@@ -63,7 +65,7 @@ type Direct struct {
dialer *tsdial.Dialer
dnsCache *dnscache.Resolver
serverURL string // URL of the tailcontrol server
timeNow func() time.Time
clock tstime.Clock
lastPrintMap time.Time
newDecompressor func() (Decompressor, error)
keepAlive bool
@@ -105,8 +107,8 @@ type Options struct {
GetMachinePrivateKey func() (key.MachinePrivate, error) // returns the machine key to use
ServerURL string // URL of the tailcontrol server
AuthKey string // optional node auth key for auto registration
TimeNow func() time.Time // time.Now implementation used by Client
Hostinfo *tailcfg.Hostinfo // non-nil passes ownership, nil means to use default using os.Hostname, etc
Clock tstime.Clock
Hostinfo *tailcfg.Hostinfo // non-nil passes ownership, nil means to use default using os.Hostname, etc
DiscoPublicKey key.DiscoPublic
NewDecompressor func() (Decompressor, error)
KeepAlive bool
@@ -170,7 +172,7 @@ type ControlDialPlanner interface {
// Pinger is the LocalBackend.Ping method.
type Pinger interface {
// Ping is a request to do a ping with the peer handling the given IP.
Ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType) (*ipnstate.PingResult, error)
Ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType, size int) (*ipnstate.PingResult, error)
}
type Decompressor interface {
@@ -178,6 +180,16 @@ type Decompressor interface {
Close()
}
// NetmapUpdater is the interface needed by the controlclient to enact change in
// the world as a function of updates received from the network.
type NetmapUpdater interface {
UpdateFullNetmap(*netmap.NetworkMap)
// TODO(bradfitz): add methods to do fine-grained updates, mutating just
// parts of peers, without implementations of NetmapUpdater needing to do
// the diff themselves between the previous full & next full network maps.
}
// NewDirect returns a new Direct client.
func NewDirect(opts Options) (*Direct, error) {
if opts.ServerURL == "" {
@@ -191,8 +203,8 @@ func NewDirect(opts Options) (*Direct, error) {
if err != nil {
return nil, err
}
if opts.TimeNow == nil {
opts.TimeNow = time.Now
if opts.Clock == nil {
opts.Clock = tstime.StdClock{}
}
if opts.Logf == nil {
// TODO(apenwarr): remove this default and fail instead.
@@ -235,7 +247,7 @@ func NewDirect(opts Options) (*Direct, error) {
httpc: httpc,
getMachinePrivKey: opts.GetMachinePrivateKey,
serverURL: opts.ServerURL,
timeNow: opts.TimeNow,
clock: opts.Clock,
logf: opts.Logf,
newDecompressor: opts.NewDecompressor,
keepAlive: opts.KeepAlive,
@@ -258,10 +270,8 @@ func NewDirect(opts Options) (*Direct, error) {
if opts.Hostinfo == nil {
c.SetHostinfo(hostinfo.New())
} else {
ni := opts.Hostinfo.NetInfo
opts.Hostinfo.NetInfo = nil
c.SetHostinfo(opts.Hostinfo)
if ni != nil {
if ni := opts.Hostinfo.NetInfo; ni != nil {
c.SetNetInfo(ni)
}
}
@@ -293,6 +303,8 @@ func (c *Direct) SetHostinfo(hi *tailcfg.Hostinfo) bool {
if hi == nil {
panic("nil Hostinfo")
}
hi = ptr.To(*hi)
hi.NetInfo = nil
c.mu.Lock()
defer c.mu.Unlock()
@@ -432,7 +444,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
authKey, isWrapped, wrappedSig, wrappedKey := decodeWrappedAuthkey(c.authKey, c.logf)
hi := c.hostInfoLocked()
backendLogID := hi.BackendLogID
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.timeNow())
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.clock.Now())
c.mu.Unlock()
machinePrivKey, err := c.getMachinePrivKey()
@@ -537,7 +549,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
err = errors.New("hostinfo: BackendLogID missing")
return regen, opt.URL, nil, err
}
now := time.Now().Round(time.Second)
now := c.clock.Now().Round(time.Second)
request := tailcfg.RegisterRequest{
Version: 1,
OldNodeKey: oldNodeKey,
@@ -559,7 +571,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
request.NodeKey.ShortString(), opt.URL != "", len(nodeKeySignature) > 0)
request.Auth.Oauth2Token = opt.Token
request.Auth.Provider = persist.Provider
request.Auth.LoginName = persist.LoginName
request.Auth.LoginName = persist.UserProfile.LoginName
request.Auth.AuthKey = authKey
err = signRegisterRequest(&request, c.serverURL, c.serverKey, machinePrivKey.Public())
if err != nil {
@@ -645,9 +657,6 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
if resp.Login.Provider != "" {
persist.Provider = resp.Login.Provider
}
if resp.Login.LoginName != "" {
persist.LoginName = resp.Login.LoginName
}
persist.UserProfile = tailcfg.UserProfile{
ID: resp.User.ID,
DisplayName: resp.Login.DisplayName,
@@ -768,29 +777,38 @@ func (c *Direct) SetEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
return c.newEndpoints(endpoints)
}
// PollNetMap makes a /map request to download the network map, calling cb with
// each new netmap.
func (c *Direct) PollNetMap(ctx context.Context, cb func(*netmap.NetworkMap)) error {
return c.sendMapRequest(ctx, -1, false, cb)
// PollNetMap makes a /map request to download the network map, calling
// NetmapUpdater on each update from the control plane.
//
// It always returns a non-nil error describing the reason for the failure or
// why the request ended.
func (c *Direct) PollNetMap(ctx context.Context, nu NetmapUpdater) error {
return c.sendMapRequest(ctx, true, nu)
}
// FetchNetMap fetches the netmap once.
func (c *Direct) FetchNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
var ret *netmap.NetworkMap
err := c.sendMapRequest(ctx, 1, false, func(nm *netmap.NetworkMap) {
ret = nm
})
if err == nil && ret == nil {
type rememberLastNetmapUpdater struct {
last *netmap.NetworkMap
}
func (nu *rememberLastNetmapUpdater) UpdateFullNetmap(nm *netmap.NetworkMap) {
nu.last = nm
}
// FetchNetMapForTest fetches the netmap once.
func (c *Direct) FetchNetMapForTest(ctx context.Context) (*netmap.NetworkMap, error) {
var nu rememberLastNetmapUpdater
err := c.sendMapRequest(ctx, false, &nu)
if err == nil && nu.last == nil {
return nil, errors.New("[unexpected] sendMapRequest success without callback")
}
return ret, err
return nu.last, err
}
// SendLiteMapUpdate makes a /map request to update the server of our latest state,
// but does not fetch anything. It returns an error if the server did not return a
// SendUpdate makes a /map request to update the server of our latest state, but
// does not fetch anything. It returns an error if the server did not return a
// successful 200 OK response.
func (c *Direct) SendLiteMapUpdate(ctx context.Context) error {
return c.sendMapRequest(ctx, 1, false, nil)
func (c *Direct) SendUpdate(ctx context.Context) error {
return c.sendMapRequest(ctx, false, nil)
}
// If we go more than pollTimeout without hearing from the server,
@@ -798,12 +816,21 @@ func (c *Direct) SendLiteMapUpdate(ctx context.Context) error {
// every minute.
const pollTimeout = 120 * time.Second
// cb nil means to omit peers.
func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool, cb func(*netmap.NetworkMap)) error {
// sendMapRequest makes a /map request to download the network map, calling cb
// with each new netmap. If isStreaming, it will poll forever and only returns
// if the context expires or the server returns an error/closes the connection
// and as such always returns a non-nil error.
//
// If cb is nil, OmitPeers will be set to true.
func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu NetmapUpdater) error {
if isStreaming && nu == nil {
panic("cb must be non-nil if isStreaming is true")
}
metricMapRequests.Add(1)
metricMapRequestsActive.Add(1)
defer metricMapRequestsActive.Add(-1)
if maxPolls == -1 {
if isStreaming {
metricMapRequestsPoll.Add(1)
} else {
metricMapRequestsLite.Add(1)
@@ -839,8 +866,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
return errors.New("hostinfo: BackendLogID missing")
}
allowStream := maxPolls != 1
c.logf("[v1] PollNetMap: stream=%v ep=%v", allowStream, epStrs)
c.logf("[v1] PollNetMap: stream=%v ep=%v", isStreaming, epStrs)
vlogf := logger.Discard
if DevKnob.DumpNetMaps() {
@@ -856,23 +882,11 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
DiscoKey: c.discoPubKey,
Endpoints: epStrs,
EndpointTypes: epTypes,
Stream: allowStream,
Stream: isStreaming,
Hostinfo: hi,
DebugFlags: c.debugFlags,
OmitPeers: cb == nil,
OmitPeers: nu == nil,
TKAHead: c.tkaHead,
// Previously we'd set ReadOnly to true if we didn't have any endpoints
// yet as we expected to learn them in a half second and restart the full
// streaming map poll, however as we are trying to reduce the number of
// times we restart the full streaming map poll we now just set ReadOnly
// false when we're doing a full streaming map poll.
//
// TODO(maisem/bradfitz): really ReadOnly should be set to true if for
// all streams and we should only do writes via lite map updates.
// However that requires an audit and a bunch of testing to make sure we
// don't break anything.
ReadOnly: readOnly && !allowStream,
}
var extraDebugFlags []string
if hi != nil && c.netMon != nil && !c.skipIPForwardingCheck &&
@@ -904,7 +918,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
defer cancel()
machinePubKey := machinePrivKey.Public()
t0 := time.Now()
t0 := c.clock.Now()
// Url and httpc are protocol specific.
var url string
@@ -942,12 +956,12 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
health.NoteMapRequestHeard(request)
if cb == nil {
if nu == nil {
io.Copy(io.Discard, res.Body)
return nil
}
timeout := time.NewTimer(pollTimeout)
timeout, timeoutChannel := c.clock.NewTimer(pollTimeout)
timeoutReset := make(chan struct{})
pollDone := make(chan struct{})
defer close(pollDone)
@@ -957,14 +971,14 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
case <-pollDone:
vlogf("netmap: ending timeout goroutine")
return
case <-timeout.C:
case <-timeoutChannel:
c.logf("map response long-poll timed out!")
cancel()
return
case <-timeoutReset:
if !timeout.Stop() {
select {
case <-timeout.C:
case <-timeoutChannel:
case <-pollDone:
vlogf("netmap: ending timeout goroutine")
return
@@ -989,7 +1003,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
// the same format before just closing the connection.
// We can use this same read loop either way.
var msg []byte
for i := 0; i < maxPolls || maxPolls < 0; i++ {
for i := 0; i == 0 || isStreaming; i++ {
vlogf("netmap: starting size read after %v (poll %v)", time.Since(t0).Round(time.Millisecond), i)
var siz [4]byte
if _, err := io.ReadFull(res.Body, siz[:]); err != nil {
@@ -1013,7 +1027,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
metricMapResponseMessages.Add(1)
if allowStream {
if isStreaming {
health.GotStreamedMapResponse()
}
@@ -1089,13 +1103,23 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
go dumpGoroutinesToURL(c.httpc, resp.Debug.GoroutineDumpURL)
}
if sleep := time.Duration(resp.Debug.SleepSeconds * float64(time.Second)); sleep > 0 {
if err := sleepAsRequested(ctx, c.logf, timeoutReset, sleep); err != nil {
if err := sleepAsRequested(ctx, c.logf, timeoutReset, sleep, c.clock); err != nil {
return err
}
}
}
nm := sess.netmapForResponse(&resp)
// Occasionally print the netmap header.
// This is handy for debugging, and our logs processing
// pipeline depends on it. (TODO: Remove this dependency.)
// Code elsewhere prints netmap diffs every time they are received.
now := c.clock.Now()
if now.Sub(c.lastPrintMap) >= 5*time.Minute {
c.lastPrintMap = now
c.logf("[v1] new network map[%d]:\n%s", i, nm.VeryConcise())
}
if nm.SelfNode == nil {
c.logf("MapResponse lacked node")
return errors.New("MapResponse lacked node")
@@ -1115,21 +1139,21 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
nm.SelfNode.Capabilities = nil
}
// Occasionally print the netmap header.
// This is handy for debugging, and our logs processing
// pipeline depends on it. (TODO: Remove this dependency.)
// Code elsewhere prints netmap diffs every time they are received.
now := c.timeNow()
if now.Sub(c.lastPrintMap) >= 5*time.Minute {
c.lastPrintMap = now
c.logf("[v1] new network map[%d]:\n%s", i, nm.VeryConcise())
}
newPersist := persist.AsStruct()
newPersist.NodeID = nm.SelfNode.StableID
newPersist.UserProfile = nm.UserProfiles[nm.User]
c.mu.Lock()
// If we are the ones who last updated persist, then we can update it
// again. Otherwise, we should not touch it.
if persist == c.persist {
c.persist = newPersist.View()
persist = c.persist
}
c.expiry = &nm.Expiry
c.mu.Unlock()
cb(nm)
nu.UpdateFullNetmap(nm)
}
if ctx.Err() != nil {
return ctx.Err()
@@ -1297,7 +1321,7 @@ func initDevKnob() devKnobs {
}
}
var clockNow = time.Now
var clock tstime.Clock = tstime.StdClock{}
// opt.Bool configs from control.
var (
@@ -1401,9 +1425,9 @@ func answerHeadPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest) {
if pr.Log {
logf("answerHeadPing: sending HEAD ping to %v ...", pr.URL)
}
t0 := time.Now()
t0 := clock.Now()
_, err = c.Do(req)
d := time.Since(t0).Round(time.Millisecond)
d := clock.Since(t0).Round(time.Millisecond)
if err != nil {
logf("answerHeadPing error: %v to %v (after %v)", err, pr.URL, d)
} else if pr.Log {
@@ -1449,7 +1473,7 @@ func answerC2NPing(logf logger.Logf, c2nHandler http.Handler, c *http.Client, pr
if pr.Log {
logf("answerC2NPing: sending POST ping to %v ...", pr.URL)
}
t0 := time.Now()
t0 := clock.Now()
_, err = c.Do(req)
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
@@ -1459,7 +1483,7 @@ func answerC2NPing(logf logger.Logf, c2nHandler http.Handler, c *http.Client, pr
}
}
func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<- struct{}, d time.Duration) error {
func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<- struct{}, d time.Duration, clock tstime.Clock) error {
const maxSleep = 5 * time.Minute
if d > maxSleep {
logf("sleeping for %v, capped from server-requested %v ...", maxSleep, d)
@@ -1468,20 +1492,20 @@ func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<-
logf("sleeping for server-requested %v ...", d)
}
ticker := time.NewTicker(pollTimeout / 2)
ticker, tickerChannel := clock.NewTicker(pollTimeout / 2)
defer ticker.Stop()
timer := time.NewTimer(d)
timer, timerChannel := clock.NewTimer(d)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
case <-timerChannel:
return nil
case <-ticker.C:
case <-tickerChannel:
select {
case timeoutReset <- struct{}{}:
case <-timer.C:
case <-timerChannel:
return nil
case <-ctx.Done():
return ctx.Err()
@@ -1658,12 +1682,12 @@ func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pin
logf("invalid ping request: missing url, ip or pinger")
return
}
start := time.Now()
start := clock.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := pinger.Ping(ctx, pr.IP, pingType)
res, err := pinger.Ping(ctx, pr.IP, pingType, 0)
if err != nil {
d := time.Since(start).Round(time.Millisecond)
logf("doPingerPing: ping error of type %q to %v after %v: %v", pingType, pr.IP, d, err)
@@ -1696,7 +1720,7 @@ func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailc
if pr.Log {
logf("postPingResult: sending ping results to %v ...", pr.URL)
}
t0 := time.Now()
t0 := clock.Now()
_, err = c.Do(req)
d := time.Since(t0).Round(time.Millisecond)
if err != nil {

View File

@@ -42,7 +42,10 @@ func TestNewDirect(t *testing.T) {
t.Errorf("c.serverURL got %v want %v", c.serverURL, opts.ServerURL)
}
if !hi.Equal(c.hostinfo) {
// hi is stored without its NetInfo field.
hiWithoutNi := *hi
hiWithoutNi.NetInfo = nil
if !hiWithoutNi.Equal(c.hostinfo) {
t.Errorf("c.hostinfo got %v want %v", c.hostinfo, hi)
}

View File

@@ -90,9 +90,28 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo
ms.lastUserProfile[up.ID] = up
}
if resp.DERPMap != nil {
if dm := resp.DERPMap; dm != nil {
ms.vlogf("netmap: new map contains DERP map")
ms.lastDERPMap = resp.DERPMap
// Zero-valued fields in a DERPMap mean that we're not changing
// anything and are using the previous value(s).
if ldm := ms.lastDERPMap; ldm != nil {
if dm.Regions == nil {
dm.Regions = ldm.Regions
dm.OmitDefaultRegions = ldm.OmitDefaultRegions
}
if dm.HomeParams == nil {
dm.HomeParams = ldm.HomeParams
} else if oldhh := ldm.HomeParams; oldhh != nil {
// Propagate sub-fields of HomeParams
hh := dm.HomeParams
if hh.RegionScore == nil {
hh.RegionScore = oldhh.RegionScore
}
}
}
ms.lastDERPMap = dm
}
if pf := resp.PacketFilter; pf != nil {
@@ -288,7 +307,7 @@ func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
for _, n := range newFull {
peerByID[n.ID] = n
}
now := clockNow()
now := clock.Now()
for nodeID, seen := range mapRes.PeerSeenChange {
if n, ok := peerByID[nodeID]; ok {
if seen {

View File

@@ -14,6 +14,7 @@ import (
"go4.org/mem"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/opt"
@@ -23,9 +24,6 @@ import (
func TestUndeltaPeers(t *testing.T) {
var curTime time.Time
tstest.Replace(t, &clockNow, func() time.Time {
return curTime
})
online := func(v bool) func(*tailcfg.Node) {
return func(n *tailcfg.Node) {
@@ -298,6 +296,7 @@ func TestUndeltaPeers(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
if !tt.curTime.IsZero() {
curTime = tt.curTime
tstest.Replace(t, &clock, tstime.Clock(tstest.NewClock(tstest.ClockOpts{Start: curTime})))
}
undeltaPeers(tt.mapRes, tt.prev)
if !reflect.DeepEqual(tt.mapRes.Peers, tt.want) {
@@ -619,3 +618,108 @@ func TestCopyDebugOptBools(t *testing.T) {
}
}
}
func TestDeltaDERPMap(t *testing.T) {
regions1 := map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
Nodes: []*tailcfg.DERPNode{{
Name: "derp1a",
RegionID: 1,
HostName: "derp1a" + tailcfg.DotInvalid,
IPv4: "169.254.169.254",
IPv6: "none",
}},
},
}
// As above, but with a changed IPv4 addr
regions2 := map[int]*tailcfg.DERPRegion{1: regions1[1].Clone()}
regions2[1].Nodes[0].IPv4 = "127.0.0.1"
type step struct {
got *tailcfg.DERPMap
want *tailcfg.DERPMap
}
tests := []struct {
name string
steps []step
}{
{
name: "nothing-to-nothing",
steps: []step{
{nil, nil},
{nil, nil},
},
},
{
name: "regions-sticky",
steps: []step{
{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
{&tailcfg.DERPMap{}, &tailcfg.DERPMap{Regions: regions1}},
},
},
{
name: "regions-change",
steps: []step{
{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
{&tailcfg.DERPMap{Regions: regions2}, &tailcfg.DERPMap{Regions: regions2}},
},
},
{
name: "home-params",
steps: []step{
// Send a DERP map
{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
// Send home params, want to still have the same regions
{
&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{1: 0.5},
}},
&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{1: 0.5},
}},
},
},
},
{
name: "home-params-sub-fields",
steps: []step{
// Send a DERP map with home params
{
&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{1: 0.5},
}},
&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{1: 0.5},
}},
},
// Sending a struct with a 'HomeParams' field but nil RegionScore doesn't change home params...
{
&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{RegionScore: nil}},
&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{1: 0.5},
}},
},
// ... but sending one with a non-nil and empty RegionScore field zeroes that out.
{
&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{RegionScore: map[int]float64{}}},
&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
RegionScore: map[int]float64{},
}},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ms := newTestMapSession(t)
for stepi, s := range tt.steps {
nm := ms.netmapForResponse(&tailcfg.MapResponse{DERPMap: s.got})
if !reflect.DeepEqual(nm.DERPMap, s.want) {
t.Errorf("unexpected result at step index %v; got: %s", stepi, must.Get(json.Marshal(nm.DERPMap)))
}
}
})
}
}

View File

@@ -23,6 +23,7 @@ import (
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
@@ -287,6 +288,25 @@ func (nc *NoiseClient) GetSingleUseRoundTripper(ctx context.Context) (http.Round
return nil, nil, errors.New("[unexpected] failed to reserve a request on a connection")
}
// contextErr is an error that wraps another error and is used to indicate that
// the error was because a context expired.
type contextErr struct {
err error
}
func (e contextErr) Error() string {
return e.err.Error()
}
func (e contextErr) Unwrap() error {
return e.err
}
// getConn returns a noiseConn that can be used to make requests to the
// coordination server. It may return a cached connection or create a new one.
// Dials are singleflighted, so concurrent calls to getConn may only dial once.
// As such, context values may not be respected as there are no guarantees that
// the context passed to getConn is the same as the context passed to dial.
func (nc *NoiseClient) getConn(ctx context.Context) (*noiseConn, error) {
nc.mu.Lock()
if last := nc.last; last != nil && last.canTakeNewRequest() {
@@ -295,11 +315,35 @@ func (nc *NoiseClient) getConn(ctx context.Context) (*noiseConn, error) {
}
nc.mu.Unlock()
conn, err, _ := nc.sfDial.Do(struct{}{}, nc.dial)
if err != nil {
return nil, err
for {
// We singeflight the dial to avoid making multiple connections, however
// that means that we can't simply cancel the dial if the context is
// canceled. Instead, we have to additionally check that the context
// which was canceled is our context and retry if our context is still
// valid.
conn, err, _ := nc.sfDial.Do(struct{}{}, func() (*noiseConn, error) {
c, err := nc.dial(ctx)
if err != nil {
if ctx.Err() != nil {
return nil, contextErr{ctx.Err()}
}
return nil, err
}
return c, nil
})
var ce contextErr
if err == nil || !errors.As(err, &ce) {
return conn, err
}
if ctx.Err() == nil {
// The dial failed because of a context error, but our context
// is still valid. Retry.
continue
}
// The dial failed because our context was canceled. Return the
// underlying error.
return nil, ce.Unwrap()
}
return conn, nil
}
func (nc *NoiseClient) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -344,7 +388,7 @@ func (nc *NoiseClient) Close() error {
// dial opens a new connection to tailcontrol, fetching the server noise key
// if not cached.
func (nc *NoiseClient) dial() (*noiseConn, error) {
func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
nc.mu.Lock()
connID := nc.nextID
nc.nextID++
@@ -392,7 +436,7 @@ func (nc *NoiseClient) dial() (*noiseConn, error) {
}
timeout := time.Duration(timeoutSec * float64(time.Second))
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
clientConn, err := (&controlhttp.Dialer{
@@ -407,6 +451,7 @@ func (nc *NoiseClient) dial() (*noiseConn, error) {
DialPlan: dialPlan,
Logf: nc.logf,
NetMon: nc.netMon,
Clock: tstime.StdClock{},
}).Dial(ctx)
if err != nil {
return nil, err

View File

@@ -127,7 +127,7 @@ func findIdentity(subject string, st certstore.Store) (certstore.Identity, []*x5
return nil, nil, err
}
selected, chain := selectIdentityFromSlice(subject, ids, time.Now())
selected, chain := selectIdentityFromSlice(subject, ids, clock.Now())
for _, id := range ids {
if id != selected {

View File

@@ -45,6 +45,7 @@ import (
"tailscale.com/net/tlsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/util/multierr"
)
@@ -147,13 +148,16 @@ func (a *Dialer) dial(ctx context.Context) (*ClientConn, error) {
// before we do anything.
if c.DialStartDelaySec > 0 {
a.logf("[v2] controlhttp: waiting %.2f seconds before dialing %q @ %v", c.DialStartDelaySec, a.Hostname, c.IP)
tmr := time.NewTimer(time.Duration(c.DialStartDelaySec * float64(time.Second)))
if a.Clock == nil {
a.Clock = tstime.StdClock{}
}
tmr, tmrChannel := a.Clock.NewTimer(time.Duration(c.DialStartDelaySec * float64(time.Second)))
defer tmr.Stop()
select {
case <-ctx.Done():
err = ctx.Err()
return
case <-tmr.C:
case <-tmrChannel:
}
}
@@ -319,7 +323,10 @@ func (a *Dialer) dialHost(ctx context.Context, addr netip.Addr) (*ClientConn, er
// In case outbound port 80 blocked or MITM'ed poorly, start a backup timer
// to dial port 443 if port 80 doesn't either succeed or fail quickly.
try443Timer := time.AfterFunc(a.httpsFallbackDelay(), func() { try(u443) })
if a.Clock == nil {
a.Clock = tstime.StdClock{}
}
try443Timer := a.Clock.AfterFunc(a.httpsFallbackDelay(), func() { try(u443) })
defer try443Timer.Stop()
var err80, err443 error

View File

@@ -11,6 +11,7 @@ import (
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -89,6 +90,10 @@ type Dialer struct {
drainFinished chan struct{}
omitCertErrorLogging bool
testFallbackDelay time.Duration
// tstime.Clock is used instead of time package for methods such as time.Now.
// If not specified, will default to tstime.StdClock{}.
Clock tstime.Clock
}
func strDef(v1, v2 string) string {

View File

@@ -25,6 +25,8 @@ import (
"tailscale.com/net/socks5"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -171,8 +173,12 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
}
var httpHandler http.Handler = handler
const fallbackDelay = 50 * time.Millisecond
clock := tstest.NewClock(tstest.ClockOpts{Step: 2 * fallbackDelay})
// Advance once to init the clock.
clock.Now()
if param.makeHTTPHangAfterUpgrade {
httpHandler = http.HandlerFunc(brokenMITMHandler)
httpHandler = brokenMITMHandler(clock)
}
httpServer := &http.Server{Handler: httpHandler}
go httpServer.Serve(httpLn)
@@ -203,7 +209,8 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
Dialer: new(tsdial.Dialer).SystemDial,
Logf: t.Logf,
omitCertErrorLogging: true,
testFallbackDelay: 50 * time.Millisecond,
testFallbackDelay: fallbackDelay,
Clock: clock,
}
if proxy != nil {
@@ -469,12 +476,16 @@ EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
}
}
func brokenMITMHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Upgrade", upgradeHeaderValue)
w.Header().Set("Connection", "upgrade")
w.WriteHeader(http.StatusSwitchingProtocols)
w.(http.Flusher).Flush()
<-r.Context().Done()
func brokenMITMHandler(clock tstime.Clock) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Upgrade", upgradeHeaderValue)
w.Header().Set("Connection", "upgrade")
w.WriteHeader(http.StatusSwitchingProtocols)
w.(http.Flusher).Flush()
// Advance the clock to trigger HTTPs fallback.
clock.Now()
<-r.Context().Done()
}
}
func TestDialPlan(t *testing.T) {
@@ -583,19 +594,20 @@ func TestDialPlan(t *testing.T) {
}},
want: goodAddr,
},
{
name: "multiple-priority-fast-path",
plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{
// Dials some good IPs and our bad one (which
// hangs forever), which then hits the fast
// path where we bail without waiting.
{IP: brokenAddr, Priority: 1, DialTimeoutSec: 10},
{IP: goodAddr, Priority: 1, DialTimeoutSec: 10},
{IP: other2Addr, Priority: 1, DialTimeoutSec: 10},
{IP: otherAddr, Priority: 2, DialTimeoutSec: 10},
}},
want: otherAddr,
},
// TODO(#8442): fix this test
// {
// name: "multiple-priority-fast-path",
// plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{
// // Dials some good IPs and our bad one (which
// // hangs forever), which then hits the fast
// // path where we bail without waiting.
// {IP: brokenAddr, Priority: 1, DialTimeoutSec: 10},
// {IP: goodAddr, Priority: 1, DialTimeoutSec: 10},
// {IP: other2Addr, Priority: 1, DialTimeoutSec: 10},
// {IP: otherAddr, Priority: 2, DialTimeoutSec: 10},
// }},
// want: otherAddr,
// },
{
name: "multiple-priority-slow-path",
plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{
@@ -618,12 +630,15 @@ func TestDialPlan(t *testing.T) {
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
// TODO(awly): replace this with tstest.NewClock and update the
// test to advance the clock correctly.
clock := tstime.StdClock{}
makeHandler(t, "fallback", fallbackAddr, nil)
makeHandler(t, "good", goodAddr, nil)
makeHandler(t, "other", otherAddr, nil)
makeHandler(t, "other2", other2Addr, nil)
makeHandler(t, "broken", brokenAddr, func(h http.Handler) http.Handler {
return http.HandlerFunc(brokenMITMHandler)
return brokenMITMHandler(clock)
})
dialer := closeTrackDialer{
@@ -659,6 +674,7 @@ func TestDialPlan(t *testing.T) {
drainFinished: drained,
omitCertErrorLogging: true,
testFallbackDelay: 50 * time.Millisecond,
Clock: clock,
}
conn, err := a.dial(ctx)

View File

@@ -85,7 +85,7 @@ const (
// framePeerPresent is like framePeerGone, but for other
// members of the DERP region when they're meshed up together.
framePeerPresent = frameType(0x09) // 32B pub key of peer that's connected
framePeerPresent = frameType(0x09) // 32B pub key of peer that's connected + optional 18B ip:port (16 byte IP + 2 byte BE uint16 port)
// frameWatchConns is how one DERP node in a regional mesh
// subscribes to the others in the region.
@@ -199,7 +199,7 @@ func readFrame(br *bufio.Reader, maxSize uint32, b []byte) (t frameType, frameLe
return 0, 0, fmt.Errorf("frame header size %d exceeds reader limit of %d", frameLen, maxSize)
}
n, err := io.ReadFull(br, b[:minUint32(frameLen, uint32(len(b)))])
n, err := io.ReadFull(br, b[:min(frameLen, uint32(len(b)))])
if err != nil {
return 0, 0, err
}
@@ -233,10 +233,3 @@ func writeFrame(bw *bufio.Writer, t frameType, b []byte) error {
}
return bw.Flush()
}
func minUint32(a, b uint32) uint32 {
if a < b {
return a
}
return b
}

View File

@@ -17,6 +17,7 @@ import (
"go4.org/mem"
"golang.org/x/time/rate"
"tailscale.com/syncs"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -40,6 +41,8 @@ type Client struct {
// Owned by Recv:
peeked int // bytes to discard on next Recv
readErr syncs.AtomicValue[error] // sticky (set by Recv)
clock tstime.Clock
}
// ClientOpt is an option passed to NewClient.
@@ -103,6 +106,7 @@ func newClient(privateKey key.NodePrivate, nc Conn, brw *bufio.ReadWriter, logf
meshKey: opt.MeshKey,
canAckPings: opt.CanAckPings,
isProber: opt.IsProber,
clock: tstime.StdClock{},
}
if opt.ServerPub.IsZero() {
if err := c.recvServerKey(); err != nil {
@@ -214,7 +218,7 @@ func (c *Client) send(dstKey key.NodePublic, pkt []byte) (ret error) {
defer c.wmu.Unlock()
if c.rate != nil {
pktLen := frameHeaderLen + key.NodePublicRawLen + len(pkt)
if !c.rate.AllowN(time.Now(), pktLen) {
if !c.rate.AllowN(c.clock.Now(), pktLen) {
return nil // drop
}
}
@@ -244,7 +248,7 @@ func (c *Client) ForwardPacket(srcKey, dstKey key.NodePublic, pkt []byte) (err e
c.wmu.Lock()
defer c.wmu.Unlock()
timer := time.AfterFunc(5*time.Second, c.writeTimeoutFired)
timer := c.clock.AfterFunc(5*time.Second, c.writeTimeoutFired)
defer timer.Stop()
if err := writeFrameHeader(c.bw, frameForwardPacket, uint32(keyLen*2+len(pkt))); err != nil {
@@ -359,7 +363,12 @@ func (PeerGoneMessage) msg() {}
// PeerPresentMessage is a ReceivedMessage that indicates that the client
// is connected to the server. (Only used by trusted mesh clients)
type PeerPresentMessage key.NodePublic
type PeerPresentMessage struct {
// Key is the public key of the client.
Key key.NodePublic
// IPPort is the remote IP and port of the client.
IPPort netip.AddrPort
}
func (PeerPresentMessage) msg() {}
@@ -457,7 +466,6 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
c.readErr.Store(err)
}
}()
for {
c.nc.SetReadDeadline(time.Now().Add(timeout))
@@ -543,8 +551,15 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
c.logf("[unexpected] dropping short peerPresent frame from DERP server")
continue
}
pg := PeerPresentMessage(key.NodePublicFromRaw32(mem.B(b[:keyLen])))
return pg, nil
var msg PeerPresentMessage
msg.Key = key.NodePublicFromRaw32(mem.B(b[:keyLen]))
if n >= keyLen+16+2 {
msg.IPPort = netip.AddrPortFrom(
netip.AddrFrom16([16]byte(b[keyLen:keyLen+16])).Unmap(),
binary.BigEndian.Uint16(b[keyLen+16:keyLen+16+2]),
)
}
return msg, nil
case frameRecvPacket:
var rp ReceivedPacket

View File

@@ -12,6 +12,7 @@ import (
crand "crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/binary"
"encoding/json"
"errors"
"expvar"
@@ -39,9 +40,11 @@ import (
"tailscale.com/envknob"
"tailscale.com/metrics"
"tailscale.com/syncs"
"tailscale.com/tstime"
"tailscale.com/tstime/rate"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/set"
"tailscale.com/version"
)
@@ -149,7 +152,7 @@ type Server struct {
closed bool
netConns map[Conn]chan struct{} // chan is closed when conn closes
clients map[key.NodePublic]clientSet
watchers map[*sclient]bool // mesh peer -> true
watchers set.Set[*sclient] // mesh peers
// clientsMesh tracks all clients in the cluster, both locally
// and to mesh peers. If the value is nil, that means the
// peer is only local (and thus in the clients Map, but not
@@ -164,6 +167,8 @@ type Server struct {
// maps from netip.AddrPort to a client's public key
keyOfAddr map[netip.AddrPort]key.NodePublic
clock tstime.Clock
}
// clientSet represents 1 or more *sclients.
@@ -216,8 +221,7 @@ func (s singleClient) ForeachClient(f func(*sclient)) { f(s.c) }
// All fields are guarded by Server.mu.
type dupClientSet struct {
// set is the set of connected clients for sclient.key.
// The values are all true.
set map[*sclient]bool
set set.Set[*sclient]
// last is the most recent addition to set, or nil if the most
// recent one has since disconnected and nobody else has send
@@ -258,7 +262,7 @@ func (s *dupClientSet) removeClient(c *sclient) bool {
trim := s.sendHistory[:0]
for _, v := range s.sendHistory {
if s.set[v] && (len(trim) == 0 || trim[len(trim)-1] != v) {
if s.set.Contains(v) && (len(trim) == 0 || trim[len(trim)-1] != v) {
trim = append(trim, v)
}
}
@@ -313,11 +317,12 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
clientsMesh: map[key.NodePublic]PacketForwarder{},
netConns: map[Conn]chan struct{}{},
memSys0: ms.Sys,
watchers: map[*sclient]bool{},
watchers: set.Set[*sclient]{},
sentTo: map[key.NodePublic]map[key.NodePublic]int64{},
avgQueueDuration: new(uint64),
tcpRtt: metrics.LabelMap{Label: "le"},
keyOfAddr: map[netip.AddrPort]key.NodePublic{},
clock: tstime.StdClock{},
}
s.initMetacert()
s.packetsRecvDisco = s.packetsRecvByKind.Get("disco")
@@ -467,8 +472,8 @@ func (s *Server) initMetacert() {
CommonName: fmt.Sprintf("derpkey%s", s.publicKey.UntypedHexString()),
},
// Windows requires NotAfter and NotBefore set:
NotAfter: time.Now().Add(30 * 24 * time.Hour),
NotBefore: time.Now().Add(-30 * 24 * time.Hour),
NotAfter: s.clock.Now().Add(30 * 24 * time.Hour),
NotBefore: s.clock.Now().Add(-30 * 24 * time.Hour),
// Per https://github.com/golang/go/issues/51759#issuecomment-1071147836,
// macOS requires BasicConstraints when subject == issuer:
BasicConstraintsValid: true,
@@ -494,8 +499,8 @@ func (s *Server) registerClient(c *sclient) {
s.mu.Lock()
defer s.mu.Unlock()
set := s.clients[c.key]
switch set := set.(type) {
curSet := s.clients[c.key]
switch curSet := curSet.(type) {
case nil:
s.clients[c.key] = singleClient{c}
c.debugLogf("register single client")
@@ -503,14 +508,14 @@ func (s *Server) registerClient(c *sclient) {
s.dupClientKeys.Add(1)
s.dupClientConns.Add(2) // both old and new count
s.dupClientConnTotal.Add(1)
old := set.ActiveClient()
old := curSet.ActiveClient()
old.isDup.Store(true)
c.isDup.Store(true)
s.clients[c.key] = &dupClientSet{
last: c,
set: map[*sclient]bool{
old: true,
c: true,
set: set.Set[*sclient]{
old: struct{}{},
c: struct{}{},
},
sendHistory: []*sclient{old},
}
@@ -519,9 +524,9 @@ func (s *Server) registerClient(c *sclient) {
s.dupClientConns.Add(1) // the gauge
s.dupClientConnTotal.Add(1) // the counter
c.isDup.Store(true)
set.set[c] = true
set.last = c
set.sendHistory = append(set.sendHistory, c)
curSet.set.Add(c)
curSet.last = c
curSet.sendHistory = append(curSet.sendHistory, c)
c.debugLogf("register another duplicate client")
}
@@ -530,7 +535,7 @@ func (s *Server) registerClient(c *sclient) {
}
s.keyOfAddr[c.remoteIPPort] = c.key
s.curClients.Add(1)
s.broadcastPeerStateChangeLocked(c.key, true)
s.broadcastPeerStateChangeLocked(c.key, c.remoteIPPort, true)
}
// broadcastPeerStateChangeLocked enqueues a message to all watchers
@@ -538,9 +543,13 @@ func (s *Server) registerClient(c *sclient) {
// presence changed.
//
// s.mu must be held.
func (s *Server) broadcastPeerStateChangeLocked(peer key.NodePublic, present bool) {
func (s *Server) broadcastPeerStateChangeLocked(peer key.NodePublic, ipPort netip.AddrPort, present bool) {
for w := range s.watchers {
w.peerStateChange = append(w.peerStateChange, peerConnState{peer: peer, present: present})
w.peerStateChange = append(w.peerStateChange, peerConnState{
peer: peer,
present: present,
ipPort: ipPort,
})
go w.requestMeshUpdate()
}
}
@@ -561,7 +570,7 @@ func (s *Server) unregisterClient(c *sclient) {
delete(s.clientsMesh, c.key)
s.notePeerGoneFromRegionLocked(c.key)
}
s.broadcastPeerStateChangeLocked(c.key, false)
s.broadcastPeerStateChangeLocked(c.key, netip.AddrPort{}, false)
case *dupClientSet:
c.debugLogf("removed duplicate client")
if set.removeClient(c) {
@@ -651,13 +660,21 @@ func (s *Server) addWatcher(c *sclient) {
defer s.mu.Unlock()
// Queue messages for each already-connected client.
for peer := range s.clients {
c.peerStateChange = append(c.peerStateChange, peerConnState{peer: peer, present: true})
for peer, clientSet := range s.clients {
ac := clientSet.ActiveClient()
if ac == nil {
continue
}
c.peerStateChange = append(c.peerStateChange, peerConnState{
peer: peer,
present: true,
ipPort: ac.remoteIPPort,
})
}
// And enroll the watcher in future updates (of both
// connections & disconnections).
s.watchers[c] = true
s.watchers.Add(c)
go c.requestMeshUpdate()
}
@@ -697,7 +714,7 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
done: ctx.Done(),
remoteAddr: remoteAddr,
remoteIPPort: remoteIPPort,
connectedAt: time.Now(),
connectedAt: s.clock.Now(),
sendQueue: make(chan pkt, perClientSendQueueDepth),
discoSendQueue: make(chan pkt, perClientSendQueueDepth),
sendPongCh: make(chan [8]byte, 1),
@@ -927,7 +944,7 @@ func (c *sclient) handleFrameForwardPacket(ft frameType, fl uint32) error {
return c.sendPkt(dst, pkt{
bs: contents,
enqueuedAt: time.Now(),
enqueuedAt: c.s.clock.Now(),
src: srcKey,
})
}
@@ -994,7 +1011,7 @@ func (c *sclient) handleFrameSendPacket(ft frameType, fl uint32) error {
p := pkt{
bs: contents,
enqueuedAt: time.Now(),
enqueuedAt: c.s.clock.Now(),
src: c.key,
}
return c.sendPkt(dst, p)
@@ -1345,6 +1362,7 @@ type sclient struct {
type peerConnState struct {
peer key.NodePublic
present bool
ipPort netip.AddrPort // if present, the peer's IP:port
}
// pkt is a request to write a data frame to an sclient.
@@ -1387,7 +1405,7 @@ func (c *sclient) setPreferred(v bool) {
// graphs, so not important to miss a move. But it shouldn't:
// the netcheck/re-STUNs in magicsock only happen about every
// 30 seconds.
if time.Since(c.connectedAt) > 5*time.Second {
if c.s.clock.Since(c.connectedAt) > 5*time.Second {
homeMove.Add(1)
}
}
@@ -1401,7 +1419,7 @@ func expMovingAverage(prev, newValue, alpha float64) float64 {
// recordQueueTime updates the average queue duration metric after a packet has been sent.
func (c *sclient) recordQueueTime(enqueuedAt time.Time) {
elapsed := float64(time.Since(enqueuedAt).Milliseconds())
elapsed := float64(c.s.clock.Since(enqueuedAt).Milliseconds())
for {
old := atomic.LoadUint64(c.s.avgQueueDuration)
newAvg := expMovingAverage(math.Float64frombits(old), elapsed, 0.1)
@@ -1431,7 +1449,7 @@ func (c *sclient) sendLoop(ctx context.Context) error {
}()
jitter := time.Duration(rand.Intn(5000)) * time.Millisecond
keepAliveTick := time.NewTicker(keepAlive + jitter)
keepAliveTick, keepAliveTickChannel := c.s.clock.NewTicker(keepAlive + jitter)
defer keepAliveTick.Stop()
var werr error // last write error
@@ -1461,7 +1479,7 @@ func (c *sclient) sendLoop(ctx context.Context) error {
case msg := <-c.sendPongCh:
werr = c.sendPong(msg)
continue
case <-keepAliveTick.C:
case <-keepAliveTickChannel:
werr = c.sendKeepAlive()
continue
default:
@@ -1490,7 +1508,7 @@ func (c *sclient) sendLoop(ctx context.Context) error {
case msg := <-c.sendPongCh:
werr = c.sendPong(msg)
continue
case <-keepAliveTick.C:
case <-keepAliveTickChannel:
werr = c.sendKeepAlive()
}
}
@@ -1538,12 +1556,18 @@ func (c *sclient) sendPeerGone(peer key.NodePublic, reason PeerGoneReasonType) e
}
// sendPeerPresent sends a peerPresent frame, without flushing.
func (c *sclient) sendPeerPresent(peer key.NodePublic) error {
func (c *sclient) sendPeerPresent(peer key.NodePublic, ipPort netip.AddrPort) error {
c.setWriteDeadline()
if err := writeFrameHeader(c.bw.bw(), framePeerPresent, keyLen); err != nil {
const frameLen = keyLen + 16 + 2
if err := writeFrameHeader(c.bw.bw(), framePeerPresent, frameLen); err != nil {
return err
}
_, err := c.bw.Write(peer.AppendTo(nil))
payload := make([]byte, frameLen)
_ = peer.AppendTo(payload[:0])
a16 := ipPort.Addr().As16()
copy(payload[keyLen:], a16[:])
binary.BigEndian.PutUint16(payload[keyLen+16:], ipPort.Port())
_, err := c.bw.Write(payload)
return err
}
@@ -1562,7 +1586,7 @@ func (c *sclient) sendMeshUpdates() error {
}
var err error
if pcs.present {
err = c.sendPeerPresent(pcs.peer)
err = c.sendPeerPresent(pcs.peer, pcs.ipPort)
} else {
err = c.sendPeerGone(pcs.peer, PeerGoneReasonDisconnected)
}

View File

@@ -9,45 +9,37 @@ import (
"net"
"time"
"golang.org/x/sys/unix"
"tailscale.com/net/tcpinfo"
)
func (c *sclient) statsLoop(ctx context.Context) error {
// If we can't get a TCP socket, then we can't send stats.
tcpConn := c.tcpConn()
if tcpConn == nil {
// Get the RTT initially to verify it's supported.
conn := c.tcpConn()
if conn == nil {
c.s.tcpRtt.Add("non-tcp", 1)
return nil
}
rawConn, err := tcpConn.SyscallConn()
if err != nil {
c.logf("error getting SyscallConn: %v", err)
if _, err := tcpinfo.RTT(conn); err != nil {
c.logf("error fetching initial RTT: %v", err)
c.s.tcpRtt.Add("error", 1)
return nil
}
const statsInterval = 10 * time.Second
ticker := time.NewTicker(statsInterval)
ticker, tickerChannel := c.s.clock.NewTicker(statsInterval)
defer ticker.Stop()
var (
tcpInfo *unix.TCPInfo
sysErr error
)
statsLoop:
for {
select {
case <-ticker.C:
err = rawConn.Control(func(fd uintptr) {
tcpInfo, sysErr = unix.GetsockoptTCPInfo(int(fd), unix.IPPROTO_TCP, unix.TCP_INFO)
})
if err != nil || sysErr != nil {
case <-tickerChannel:
rtt, err := tcpinfo.RTT(conn)
if err != nil {
continue statsLoop
}
// TODO(andrew): more metrics?
rtt := time.Duration(tcpInfo.Rtt) * time.Microsecond
c.s.tcpRtt.Add(durationToLabel(rtt), 1)
case <-ctx.Done():

View File

@@ -27,6 +27,7 @@ import (
"golang.org/x/time/rate"
"tailscale.com/disco"
"tailscale.com/net/memnet"
"tailscale.com/tstest"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -91,7 +92,7 @@ func TestSendRecv(t *testing.T) {
defer cancel()
brwServer := bufio.NewReadWriter(bufio.NewReader(cin), bufio.NewWriter(cin))
go s.Accept(ctx, cin, brwServer, fmt.Sprintf("test-client-%d", i))
go s.Accept(ctx, cin, brwServer, fmt.Sprintf("[abc::def]:%v", i))
key := clientPrivateKeys[i]
brw := bufio.NewReadWriter(bufio.NewReader(cout), bufio.NewWriter(cout))
@@ -527,7 +528,7 @@ func newTestServer(t *testing.T, ctx context.Context) *testServer {
// TODO: register c in ts so Close also closes it?
go func(i int) {
brwServer := bufio.NewReadWriter(bufio.NewReader(c), bufio.NewWriter(c))
go s.Accept(ctx, c, brwServer, fmt.Sprintf("test-client-%d", i))
go s.Accept(ctx, c, brwServer, c.RemoteAddr().String())
}(i)
}
}()
@@ -614,7 +615,7 @@ func (tc *testClient) wantPresent(t *testing.T, peers ...key.NodePublic) {
}
switch m := m.(type) {
case PeerPresentMessage:
got := key.NodePublic(m)
got := m.Key
if !want[got] {
t.Fatalf("got peer present for %v; want present for %v", tc.ts.keyName(got), logger.ArgWriter(func(bw *bufio.Writer) {
for _, pub := range peers {
@@ -622,6 +623,7 @@ func (tc *testClient) wantPresent(t *testing.T, peers ...key.NodePublic) {
}
}))
}
t.Logf("got present with IP %v", m.IPPort)
delete(want, got)
if len(want) == 0 {
return
@@ -990,9 +992,10 @@ func TestClientRecv(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Client{
nc: dummyNetConn{},
br: bufio.NewReader(bytes.NewReader(tt.input)),
logf: t.Logf,
nc: dummyNetConn{},
br: bufio.NewReader(bytes.NewReader(tt.input)),
logf: t.Logf,
clock: &tstest.Clock{},
}
got, err := c.Recv()
if err != nil {
@@ -1435,7 +1438,8 @@ func (w *countWriter) ResetStats() {
func TestClientSendRateLimiting(t *testing.T) {
cw := new(countWriter)
c := &Client{
bw: bufio.NewWriter(cw),
bw: bufio.NewWriter(cw),
clock: &tstest.Clock{},
}
c.setSendRateLimiter(ServerInfoMessage{})

View File

@@ -38,8 +38,10 @@ import (
"tailscale.com/net/tshttpproxy"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/cmpx"
)
// Client is a DERP-over-HTTP client.
@@ -54,6 +56,11 @@ type Client struct {
MeshKey string // optional; for trusted clients
IsProber bool // optional; for probers to optional declare themselves as such
// BaseContext, if non-nil, returns the base context to use for dialing a
// new derp server. If nil, context.Background is used.
// In either case, additional timeouts may be added to the base context.
BaseContext func() context.Context
privateKey key.NodePrivate
logf logger.Logf
netMon *netmon.Monitor // optional; nil means interfaces will be looked up on-demand
@@ -82,6 +89,7 @@ type Client struct {
serverPubKey key.NodePublic
tlsState *tls.ConnectionState
pingOut map[derp.PingMessage]chan<- bool // chan to send to on pong
clock tstime.Clock
}
func (c *Client) String() string {
@@ -100,6 +108,7 @@ func NewRegionClient(privateKey key.NodePrivate, logf logger.Logf, netMon *netmo
getRegion: getRegion,
ctx: ctx,
cancelCtx: cancel,
clock: tstime.StdClock{},
}
return c
}
@@ -107,7 +116,7 @@ func NewRegionClient(privateKey key.NodePrivate, logf logger.Logf, netMon *netmo
// NewNetcheckClient returns a Client that's only able to have its DialRegionTLS method called.
// It's used by the netcheck package.
func NewNetcheckClient(logf logger.Logf) *Client {
return &Client{logf: logf}
return &Client{logf: logf, clock: tstime.StdClock{}}
}
// NewClient returns a new DERP-over-HTTP client. It connects lazily.
@@ -128,6 +137,7 @@ func NewClient(privateKey key.NodePrivate, serverURL string, logf logger.Logf) (
url: u,
ctx: ctx,
cancelCtx: cancel,
clock: tstime.StdClock{},
}
return c, nil
}
@@ -139,6 +149,19 @@ func (c *Client) Connect(ctx context.Context) error {
return err
}
// newContext returns a new context for setting up a new DERP connection.
// It uses either c.BaseContext or returns context.Background.
func (c *Client) newContext() context.Context {
if c.BaseContext != nil {
ctx := c.BaseContext()
if ctx == nil {
panic("BaseContext returned nil")
}
return ctx
}
return context.Background()
}
// TLSConnectionState returns the last TLS connection state, if any.
// The client must already be connected.
func (c *Client) TLSConnectionState() (_ *tls.ConnectionState, ok bool) {
@@ -643,21 +666,18 @@ func (c *Client) dialNode(ctx context.Context, n *tailcfg.DERPNode) (net.Conn, e
nwait++
go func() {
if proto == "tcp4" && c.preferIPv6() {
t := time.NewTimer(200 * time.Millisecond)
t, tChannel := c.clock.NewTimer(200 * time.Millisecond)
select {
case <-ctx.Done():
// Either user canceled original context,
// it timed out, or the v6 dial succeeded.
t.Stop()
return
case <-t.C:
case <-tChannel:
// Start v4 dial
}
}
dst := dstPrimary
if dst == "" {
dst = n.HostName
}
dst := cmpx.Or(dstPrimary, n.HostName)
port := "443"
if n.DERPPort != 0 {
port = fmt.Sprint(n.DERPPort)
@@ -774,7 +794,7 @@ func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, pr
}
func (c *Client) Send(dstKey key.NodePublic, b []byte) error {
client, _, err := c.connect(context.TODO(), "derphttp.Client.Send")
client, _, err := c.connect(c.newContext(), "derphttp.Client.Send")
if err != nil {
return err
}
@@ -874,7 +894,7 @@ func (c *Client) LocalAddr() (netip.AddrPort, error) {
}
func (c *Client) ForwardPacket(from, to key.NodePublic, b []byte) error {
client, _, err := c.connect(context.TODO(), "derphttp.Client.ForwardPacket")
client, _, err := c.connect(c.newContext(), "derphttp.Client.ForwardPacket")
if err != nil {
return err
}
@@ -940,7 +960,7 @@ func (c *Client) NotePreferred(v bool) {
//
// Only trusted connections (using MeshKey) are allowed to use this.
func (c *Client) WatchConnectionChanges() error {
client, _, err := c.connect(context.TODO(), "derphttp.Client.WatchConnectionChanges")
client, _, err := c.connect(c.newContext(), "derphttp.Client.WatchConnectionChanges")
if err != nil {
return err
}
@@ -955,7 +975,7 @@ func (c *Client) WatchConnectionChanges() error {
//
// Only trusted connections (using MeshKey) are allowed to use this.
func (c *Client) ClosePeer(target key.NodePublic) error {
client, _, err := c.connect(context.TODO(), "derphttp.Client.ClosePeer")
client, _, err := c.connect(c.newContext(), "derphttp.Client.ClosePeer")
if err != nil {
return err
}
@@ -976,7 +996,7 @@ func (c *Client) Recv() (derp.ReceivedMessage, error) {
// RecvDetail is like Recv, but additional returns the connection generation on each message.
// The connGen value is incremented every time the derphttp.Client reconnects to the server.
func (c *Client) RecvDetail() (m derp.ReceivedMessage, connGen int, err error) {
client, connGen, err := c.connect(context.TODO(), "derphttp.Client.Recv")
client, connGen, err := c.connect(c.newContext(), "derphttp.Client.Recv")
if err != nil {
return nil, 0, err
}

View File

@@ -5,6 +5,7 @@ package derphttp
import (
"context"
"net/netip"
"sync"
"time"
@@ -26,7 +27,7 @@ import (
//
// To force RunWatchConnectionLoop to return quickly, its ctx needs to
// be closed, and c itself needs to be closed.
func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key.NodePublic, infoLogf logger.Logf, add, remove func(key.NodePublic)) {
func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key.NodePublic, infoLogf logger.Logf, add func(key.NodePublic, netip.AddrPort), remove func(key.NodePublic)) {
if infoLogf == nil {
infoLogf = logger.Discard
}
@@ -51,7 +52,7 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
present = map[key.NodePublic]bool{}
}
lastConnGen := 0
lastStatus := time.Now()
lastStatus := c.clock.Now()
logConnectedLocked := func() {
if loggedConnected {
return
@@ -61,16 +62,16 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
}
const logConnectedDelay = 200 * time.Millisecond
timer := time.AfterFunc(2*time.Second, func() {
timer := c.clock.AfterFunc(2*time.Second, func() {
mu.Lock()
defer mu.Unlock()
logConnectedLocked()
})
defer timer.Stop()
updatePeer := func(k key.NodePublic, isPresent bool) {
updatePeer := func(k key.NodePublic, ipPort netip.AddrPort, isPresent bool) {
if isPresent {
add(k)
add(k, ipPort)
} else {
remove(k)
}
@@ -91,11 +92,11 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
}
sleep := func(d time.Duration) {
t := time.NewTimer(d)
t, tChannel := c.clock.NewTimer(d)
select {
case <-ctx.Done():
t.Stop()
case <-t.C:
case <-tChannel:
}
}
@@ -126,7 +127,7 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
}
switch m := m.(type) {
case derp.PeerPresentMessage:
updatePeer(key.NodePublic(m), true)
updatePeer(m.Key, m.IPPort, true)
case derp.PeerGoneMessage:
switch m.Reason {
case derp.PeerGoneReasonDisconnected:
@@ -138,11 +139,11 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
logf("Recv: peer %s not at server %s for unknown reason %v",
key.NodePublic(m.Peer).ShortString(), c.ServerPublicKey().ShortString(), m.Reason)
}
updatePeer(key.NodePublic(m.Peer), false)
updatePeer(key.NodePublic(m.Peer), netip.AddrPort{}, false)
default:
continue
}
if now := time.Now(); now.Sub(lastStatus) > statusInterval {
if now := c.clock.Now(); now.Sub(lastStatus) > statusInterval {
lastStatus = now
infoLogf("%d peers", len(present))
}

View File

@@ -94,6 +94,9 @@ type Message interface {
AppendMarshal([]byte) []byte
}
// MessageHeaderLen is the length of a message header, 2 bytes for type and version.
const MessageHeaderLen = 2
// appendMsgHeader appends two bytes (for t and ver) and then also
// dataLen bytes to b, returning the appended slice in all. The
// returned data slice is a subslice of all with just dataLen bytes of
@@ -117,15 +120,24 @@ type Ping struct {
// netmap data to reduce the discokey:nodekey relation from 1:N to
// 1:1.
NodeKey key.NodePublic
// Padding is the number of 0 bytes at the end of the
// message. (It's used to probe path MTU.)
Padding int
}
// PingLen is the length of a marshalled ping message, without the message
// header or padding.
const PingLen = 12 + key.NodePublicRawLen
func (m *Ping) AppendMarshal(b []byte) []byte {
dataLen := 12
hasKey := !m.NodeKey.IsZero()
if hasKey {
dataLen += key.NodePublicRawLen
}
ret, d := appendMsgHeader(b, TypePing, v0, dataLen)
ret, d := appendMsgHeader(b, TypePing, v0, dataLen+m.Padding)
n := copy(d, m.TxID[:])
if hasKey {
m.NodeKey.AppendTo(d[:n])
@@ -138,11 +150,14 @@ func parsePing(ver uint8, p []byte) (m *Ping, err error) {
return nil, errShort
}
m = new(Ping)
m.Padding = len(p)
p = p[copy(m.TxID[:], p):]
m.Padding -= 12
// Deliberately lax on longer-than-expected messages, for future
// compatibility.
if len(p) >= key.NodePublicRawLen {
m.NodeKey = key.NodePublicFromRaw32(mem.B(p[:key.NodePublicRawLen]))
m.Padding -= key.NodePublicRawLen
}
return m, nil
}
@@ -214,6 +229,8 @@ type Pong struct {
Src netip.AddrPort // 18 bytes (16+2) on the wire; v4-mapped ipv6 for IPv4
}
// pongLen is the length of a marshalled pong message, without the message
// header or padding.
const pongLen = 12 + 16 + 2
func (m *Pong) AppendMarshal(b []byte) []byte {

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