Compare commits

...

132 Commits

Author SHA1 Message Date
Andrea Gottardo
0d8033fd83 mdm: read user defaults + registry proto
Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
2023-09-13 10:26:50 -07:00
Brad Fitzpatrick
99bb355791 wgengine: remove DiscoKey method from Engine interface
It has one user (LocalBackend) which can ask magicsock itself.

Updates #cleanup

Change-Id: I8c03cbb1e5ba57b0b442621b5fa467030c14a2e2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-13 10:01:44 -07:00
Will Norris
9843e922b8 README: update docs for building web client
Move into the main "building" section and add the missing `yarn install`
step.

Updates #9312

Signed-off-by: Will Norris <will@tailscale.com>
2023-09-13 09:38:40 -07:00
Tyler Smalley
82c1dd8732 cmd/tailscale: funnel wip cleanup and additional test coverage (#9316)
General cleanup and additional test coverage of WIP code.

* use enum for serveType
* combine instances of ServeConfig access within unset
* cleanMountPoint rewritten into cleanURLPath as it only handles URL paths
* refactor and test expandProxyTargetDev

> **Note**
> Behind the `TAILSCALE_USE_WIP_CODE` flag

updates #8489

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-09-13 10:41:30 -05:00
Brad Fitzpatrick
3c276d7de2 wgengine: remove SetDERPMap method from Engine interface
(continuing the mission of removing rando methods from the Engine
 interface that we don't need anymore)

Updates #cleanup

Change-Id: Id5190917596bf04d7185c3b331a852724a3f5a16
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 21:16:56 -07:00
Brad Fitzpatrick
67396d716b ipn/ipnlocal: remove defensiveness around not having a magicsock.Conn
We always have one. Stop pretending we might not.

Instead, add one early panic in NewLocalBackend if we actually don't.

Updates #cleanup

Change-Id: Iba4b78ed22cb6248e59c2b01a79355ca7a200ec8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 20:52:08 -07:00
Brad Fitzpatrick
b8a4c96c53 wgengine: remove LinkChange method from Engine interface
It was only used by Android, until
https://github.com/tailscale/tailscale-android/pull/131
which does the call to the netMon directly instead.

Updates #cleanup

Change-Id: Iab8a1d8f1e63250705835c75f40e2cd8c1c4d5b8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 17:25:17 -07:00
Brad Fitzpatrick
727b1432a8 wgengine: remove SetNetInfoCallback method from Engine
LocalBackend can talk to magicsock on its own to do this without
the "Engine" being involved.

(Continuing a little side quest of cleaning up the Engine
interface...)

Updates #cleanup

Change-Id: I8654acdca2b883b1bd557fdc0cfb90cd3a418a62
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 15:14:14 -07:00
Andrew Dunham
ad4c11aca1 net/netmon: log when the gateway/self IP changes
This logs that the gateway/self IP address has changed if one of the new
values differs.

Updates #8992

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I0919424b68ad97fbe1204dd36317ed6f5915411f
2023-09-12 17:48:29 -04:00
License Updater
45eafe1b06 licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-09-12 14:11:00 -07:00
Brad Fitzpatrick
eb9f1db269 cmd/tsconnect/wasm: register netstack.Impl with tsd.System
I missed this in 343c0f1031 and I guess we don't have integration
tests for wasm. But it compiled! :)

Updates #fixup to a #cleanup

Change-Id: If147b90bab254d144ec851a392e8db10ab97f98e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 14:10:06 -07:00
Brad Fitzpatrick
343c0f1031 wgengine{,/netstack}: remove AddNetworkMapCallback from Engine interface
It had exactly one user: netstack. Just have LocalBackend notify
netstack when here's a new netmap instead, simplifying the bloated
Engine interface that has grown a bunch of non-Engine-y things.
(plenty of rando stuff remains after this, but it's a start)

Updates #cleanup

Change-Id: I45e10ab48119e962fc4967a95167656e35b141d8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 13:55:57 -07:00
David Crawshaw
47ffbffa97 clientupdate: add root key (#9364)
Signed-off-by: David Crawshaw <crawshaw@tailscale.com>
2023-09-12 15:24:01 -05:00
Brad Fitzpatrick
39ade4d0d4 tstest/integration: add start of integration tests for incremental map updates
This adds a new integration test with two nodes where the first gets a
incremental MapResponse (with only PeersRemoved set) saying that the
second node disappeared.

This extends the testcontrol package to support sending raw
MapResponses to nodes.

Updates #1909

Change-Id: Iea0c25c19cf0d72b52dba5a46d01b5cc87b9b39d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 12:47:38 -07:00
Brad Fitzpatrick
9203916a4a control/controlknobs: move more controlknobs code from controlclient
Updates #cleanup

Change-Id: I2b8b6ac97589270f307bfb20e33674894ce873b5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 12:44:35 -07:00
Brad Fitzpatrick
3af051ea27 control/controlclient, types/netmap: start plumbing delta netmap updates
Currently only the top four most popular changes: endpoints, DERP
home, online, and LastSeen.

Updates #1909

Change-Id: I03152da176b2b95232b56acabfb55dcdfaa16b79
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 12:23:24 -07:00
Andrew Lytvynov
c0ade132e6 clientupdate: restart tailscale after install on DSM6 (#9363)
DSM6 does not automatically restart packages on install, we have to do
it explicitly.

Also, DSM6 has a filter for publishers in Package Center. Make the error
message more helpful when update fails because of this filter not
allowing our package.

Fixes #9361

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-09-12 13:08:00 -05:00
Brad Fitzpatrick
668a0dd5ab cmd/tailscale/cli: fix panic in netcheck print when no DERP home
Fixes #8016

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 08:47:45 -07:00
Andrew Dunham
9ee173c256 net/portmapper: fall back to permanent UPnP leases if necessary
Some routers don't support lease times for UPnP portmapping; let's fall
back to adding a permanent lease in these cases. Additionally, add a
proper end-to-end test case for the UPnP portmapping behaviour.

Updates #9343

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I17dec600b0595a5bfc9b4d530aff6ee3109a8b12
2023-09-12 11:16:45 -04:00
Brad Fitzpatrick
7c1ed38ab3 ipn/ipnlocal: fix missing controlknobs.Knobs plumbing
I missed connecting some controlknobs.Knobs pieces in 4e91cf20a8
resulting in that breaking control knobs entirely.

Whoops.

The fix in ipn/ipnlocal (where it makes a new controlclient) but to
atone, I also added integration tests. Those integration tests use
a new "tailscale debug control-knobs" which by itself might be useful
for future debugging.

Updates #9351

Change-Id: Id9c89c8637746d879d5da67b9ac4e0d2367a3f0d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-12 06:17:14 -07:00
Marwan Sulaiman
12d4685328 ipn/localapi, ipn/ipnlocal: add etag support for SetServeConfig
This PR adds optimistic concurrency control in the local client and
api in order to ensure multiple writes of the ServeConfig do not
conflict with each other.

Updates #9273

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-09-12 04:41:10 -04:00
Brad Fitzpatrick
ff6fadddb6 wgengine/magicsock: stop retaining *netmap.NetworkMap
We're trying to start using that monster type less and eventually get
rid of it.

Updates #1909

Change-Id: I8e1e725bce5324fb820a9be6c7952767863e6542
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-11 20:07:30 -07:00
Brad Fitzpatrick
f06e64c562 wgengine: use set.HandleSet in another place
I guess we missed this one earlier when we unified the various
copies into set.HandleSet.

Updates #cleanup

Change-Id: I7e6de9ce16e8fc4846abf384dfcc8eaec4d99e60
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-11 20:07:05 -07:00
Brad Fitzpatrick
42072683d6 control/controlknobs: move ForceBackgroundSTUN to controlknobs.Knobs
This is both more efficient (because the knobs' bool is only updated
whenever Node is changed, rarely) and also gets us one step closer to
removing a case of storing a netmap.NetworkMap in
magicsock. (eventually we want to phase out much of the use of that
type internally)

Updates #1909

Change-Id: I37e81789f94133175064fdc09984e4f3a431f1a1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-11 18:11:09 -07:00
Brad Fitzpatrick
4e91cf20a8 control/controlknobs, all: add plumbed Knobs type, not global variables
Previously two tsnet nodes in the same process couldn't have disjoint
sets of controlknob settings from control as both would overwrite each
other's global variables.

This plumbs a new controlknobs.Knobs type around everywhere and hangs
the knobs sent by control on that instead.

Updates #9351

Change-Id: I75338646d36813ed971b4ffad6f9a8b41ec91560
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-11 12:44:03 -07:00
Brad Fitzpatrick
d050700a3b wgengine/magicsock: make peerMap also keyed by NodeID
In prep for incremental netmap update plumbing (#1909), make peerMap
also keyed by NodeID, as all the netmap node mutations passed around
later will be keyed by NodeID.

In the process, also:

* add envknob.InDevMode, as a signal that we can panic more aggressively
  in unexpected cases.
* pull two moderately large blocks of code in Conn.SetNetworkMap out
  into their own methods
* convert a few more sets from maps to set.Set

Updates #1909

Change-Id: I7acdd64452ba58e9d554140ee7a8760f9043f961
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-11 12:43:47 -07:00
Paul Scott
683ba62f3e cmd/testwrapper: fix exit deflake (#9342)
Sometimes `go test` would exit and close its stdout before we started reading
it, and we would return that "file closed" error then forget to os.Exit(1).
Fixed to prefer the go test subprocess error and exit regardless of the type of
error.

Fixes #9334

Signed-off-by: Paul Scott <paul@tailscale.com>
2023-09-11 19:06:11 +01:00
Brad Fitzpatrick
0396366aae cmd/testwrapper/flakytest: don't spam stderr in Mark when not under wrapper
If the user's running "go test" by hand, no need to spam stderr with
the sentinel marker. It already calls t.Logf (which only gets output
on actual failure, or verbose mode) which is enough to tell users it's
known flaky. Stderr OTOH always prints out and is distracting to
manual "go test" users.

Updates #cleanup

Change-Id: Ie5e6881bae291787c30f75924fa132f4a28abbb2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-11 11:04:18 -07:00
Brad Fitzpatrick
70ea073478 tailcfg: flesh out some docs on MapResponse, clarify slices w/ omitempty
Updates #cleanup

Change-Id: If4caf9d00529edc09ae7af9cc70f6ba0ade38378
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-10 09:22:41 -07:00
Brad Fitzpatrick
a5ffd5e7c3 tailcfg: remove unused MapRequest.IncludeIPv6 field
It's been implicitly enabled (based on capver) for years.

Updates #cleanup

Change-Id: I8ff1ab844f9ed75c97e866e778dfc0b56cfa98a2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-10 09:21:07 -07:00
Brad Fitzpatrick
9a86aa5732 all: depend on zstd unconditionally, remove plumbing to make it optional
All platforms use it at this point, including iOS which was the
original hold out for memory reasons. No more reason to make it
optional.

Updates #9332

Change-Id: I743fbc2f370921a852fbcebf4eb9821e2bdd3086
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-10 08:36:05 -07:00
Marwan Sulaiman
f12c71e71c cmd/tailscale: reduce duplicate calls to LocalBackend
This PR ensures calls to the LocalBackend are not happening
multiples times and ensures the set/unset methods are
only manipulating the serve config

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-09-10 11:31:57 -04:00
Brad Fitzpatrick
dc7aa98b76 all: use set.Set consistently instead of map[T]struct{}
I didn't clean up the more idiomatic map[T]bool with true values, at
least yet.  I just converted the relatively awkward struct{}-valued
maps.

Updates #cleanup

Change-Id: I758abebd2bb1f64bc7a9d0f25c32298f4679c14f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-09 10:59:19 -07:00
Brad Fitzpatrick
d506a55c8a ipn/ipnstate: address TODO about garbage during peer sorting
Updates #cleanup

Change-Id: I34938bca70a95571cc62ce1f76eaab5db8c2c3ef
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-09 09:12:23 -07:00
Brad Fitzpatrick
60e9bd6047 ipn/ipnstate: add some missing docs
Updates #cleanup

Change-Id: I689f8124a5986a98b8eb3891727d39c96408f0a7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-09 08:55:43 -07:00
Brad Fitzpatrick
db307d35e1 types/netmap: delete a copy of views.SliceEqual
Updates #cleanup

Change-Id: Ibdfa6c5dc9211f5c97c763ba323802a1c1d80c9e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-08 20:49:10 -07:00
David Anderson
95082a8dde util/lru, util/limiter: add debug helper to dump state as HTML
For use in tsweb debug handlers, so that we can easily inspect cache
and limiter state when troubleshooting.

Updates tailscale/corp#3601

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-09-08 14:47:03 -07:00
Andrew Lytvynov
d23b8ffb13 cmd/tailscale/cli,ipn: mention available update in "tailscale status" (#9205)
Cache the last `ClientVersion` value that was received from coordination
server and pass it in the localapi `/status` response.
When running `tailscale status`, print a message if `RunningAsLatest` is
`false`.

Updates #6907

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-09-08 14:27:49 -07:00
Andrew Lytvynov
1073b56e18 ipn/ipnlocal: add logging and locking to c2n /update (#9290)
Log some progress info to make updates more debuggable. Also, track
whether an active update is already started and return an error if
a concurrent update is attempted.

Some planned future PRs:
* add JSON output to `tailscale update`
* use JSON output from `tailscale update` to provide a more detailed
  status of in-progress update (stage, download progress, etc)

Updates #6907

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-09-08 14:26:55 -07:00
Sonia Appasamy
1eadb2b608 client/web: clean up assets handling
A #cleanup that moves all frontend asset handling into assets.go
(formerly dev.go), and stores a single assetsHandler field back
to web.Server that manages when to serve the dev vite proxy versus
static files itself.

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-09-08 16:05:11 -04:00
License Updater
4a38d8d372 licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-09-08 08:44:43 -07:00
License Updater
0dc65b2e47 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-09-08 08:42:58 -07:00
License Updater
1383fc57ad licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-09-08 08:41:56 -07:00
Joe Tsai
0a0adb68ad ssh/tailssh: log when recording starts and finishes (#9294)
Updates tailscale/corp#14579

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-09-07 18:47:04 -07:00
Marwan Sulaiman
a1d4144b18 cmd/tailscale: combine foreground and background serve logic
Previously, foreground mode only worked in the simple case of `tailscale funnel <port>`.
This PR ensures that whatever you can do in the background can also be
done in the foreground such as setting mount paths or tcp forwarding.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-09-07 20:48:58 -04:00
Craig Rodrigues
8452d273e3 util/linuxfw: Fix comment which lists supported linux arches
Only arm64 and amd64 are supported

Signed-off-by: Craig Rodrigues <rodrigc@crodrigues.org>
2023-09-07 16:49:50 -07:00
David Anderson
0909e90890 util/lru: replace container/list with a custom ring implementation
pre-generics container/list is quite unpleasant to use, and the pointer
manipulation operations for an LRU are simple enough to implement directly
now that we have generic types.

With this change, the LRU uses a ring (aka circularly linked list) rather
than a simple doubly-linked list as its internals, because the ring makes
list manipulation edge cases more regular: the only remaining edge case is
the transition between 0 and 1 elements, rather than also having to deal
specially with manipulating the first and last members of the list.

While the primary purpose was improved readability of the code, as it
turns out removing the indirection through an interface box also speeds
up the LRU:

       │ before.txt  │              after.txt              │
       │   sec/op    │   sec/op     vs base                │
LRU-32   67.05n ± 2%   59.73n ± 2%  -10.90% (p=0.000 n=20)

       │ before.txt │             after.txt              │
       │    B/op    │    B/op     vs base                │
LRU-32   21.00 ± 0%   10.00 ± 0%  -52.38% (p=0.000 n=20)

       │ before.txt │           after.txt            │
       │ allocs/op  │ allocs/op   vs base            │
LRU-32   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=20) ¹

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-09-07 16:04:39 -07:00
David Anderson
472eb6f6f5 util/lru: add a microbenchmark
The benchmark simulates an LRU being queries with uniformly random
inputs, in a set that's too large for the LRU, which should stress
the eviction codepath.

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-09-07 16:04:39 -07:00
Maisem Ali
18b2638b07 metrics: add missing comma in histogram JSON export
Updates tailscale/corp#8641

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-07 15:28:12 -07:00
Tyler Smalley
70a9854b39 cmd/tailscale: add background mode to serve/funnel wip (#9202)
> **Note**
> Behind the `TAILSCALE_FUNNEL_DEV` flag

* Expose additional listeners through flags
* Add a --bg flag to run in the background
* --set-path to set a path for a specific target (assumes running in background)

See the parent issue for more context.

Updates #8489

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-09-07 15:07:53 -07:00
Maisem Ali
5ee349e075 tsweb/varz: fix exporting histograms
Updates tailscale/corp#8641

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-07 14:52:59 -07:00
Brad Fitzpatrick
1bd3edbb46 release/dist/unixpkgs: demote deb iptables+iproute2 packages to recommended
Fixes #9236

Change-Id: Idbad2edb0262ef842afd6b40ae47f46e685b112d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-07 14:19:39 -07:00
Marwan Sulaiman
50990f8931 ipn, ipn/ipnlocal: add Foreground field for ServeConfig
This PR adds a new field to the serve config that can be used to identify which serves are in "foreground mode" and then can also be used to ensure they do not get persisted to disk so that if Tailscaled gets ungracefully shutdown, the reloaded ServeConfig will not have those ports opened.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-09-07 13:13:05 -04:00
Paul Scott
96094cc07e cmd/testwrapper: exit code 1 when go build fails (#9276)
Fixes #9275
Fixes #8586
Fixes tailscale/corp#13115

Signed-off-by: Paul Scott <paul@tailscale.com>
2023-09-07 17:18:26 +01:00
Skip Tavakkolian
6fd1961cd7 safesocket, paths: add Plan 9 support
Updates #5794

Change-Id: I69150ec18d101f55baabb38613512cde858447cb
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Skip Tavakkolian <skip.tavakkolian@gmail.com>
2023-09-07 08:48:21 -07:00
Marwan Sulaiman
51d3220153 ipn, ipn/ipnlocal: remove log streaming for StreamServe
This PR removes the per request logging to the CLI as the CLI
will not be displaying those logs initially.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-09-06 21:15:05 -04:00
David Anderson
96c2cd2ada util/limiter: add a keyed token bucket rate limiter
Updates tailscale/corp#3601

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-09-06 17:48:17 -07:00
Maisem Ali
c2241248c8 tstest: relax ResourceCheck to 3s
It was only waiting for 0.5s (5ms * 100), but our CI
is too slow so make it wait up to 3s (10ms * 300).

Updates tailscale/corp#14515

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-06 15:42:08 -07:00
Chris Palmer
ac7b4d62fd cmd/tailscale/cli: make update visible in list (#8662)
This also makes "HIDDEN: " work (requires the custom UsageFunc).

Updates #6995

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
2023-09-06 10:28:04 -07:00
Andrew Dunham
d413dd7ee5 net/dns/publicdns: add support for Wikimedia DNS
RELNOTE=Adds support for Wikimedia DNS

Updates #9255

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I4213c29e0f91ea5aa0304a5a026c32b6690fead9
2023-09-06 11:38:15 -04:00
Andrea Barisani
d61494db68 adjust build tags for tamago
Signed-off-by: Andrea Barisani <andrea@inversepath.com>
2023-09-06 05:50:18 -07:00
Paul Scott
9a56184bef cmd/tailscale: Check App Store tailscaled dialable before selecting. (#9234)
PR #9217 attempted to fix the same issue, but suffered from not letting the
user connect to non-oss tailscaled if something was listening on the socket, as
the --socket flag doesn't let you select the mac apps.

Rather than leave the user unable to choose, we keep the mac/socket preference
order the same and check a bit harder whether the macsys version really is
running. Now, we prefer the App Store Tailscale (even if it's Stopped) and you
can use --socket to sswitch. But if you quit the App Store Tailscale, we'll try
the socket without needing the flag.

Fixes #5761
Signed-off-by: Paul Scott <408401+icio@users.noreply.github.com>
2023-09-06 12:43:10 +01:00
Anton Tolchanov
86b0fc5295 util/cmpver: add a few tests covering different OS versions
Updates tailscale/corp#14491

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-09-06 09:43:40 +01:00
Aaron Klotz
7686ff6c46 Update clientupdate/distsign/distsign_test.go
Co-authored-by: Andrew Lytvynov <awly@tailscale.com>
Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-09-05 15:43:36 -06:00
Aaron Klotz
7d60c19d7d clientupdate/distsign: add ability to validate a binary that is already located on disk
Our build system caches files locally and only updates them when something
changes. Since I need to integrate some distsign stuff into the build system
to validate our Windows 7 MSIs, I want to be able to check the cached copy
of a package before downloading a fresh copy from pkgs.

If the signature changes, then obviously the local copy is outdated and we
return an error, at which point we call Download to refresh the package.

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

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-09-05 15:43:36 -06:00
Maisem Ali
f6a203fe23 control/controlclient: check c.closed in waitUnpause
We would only check if the client was paused, but not
if the client was closed. This meant that a call to
Shutdown may block forever/leak goroutines

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-05 12:31:25 -07:00
Marwan Sulaiman
45eeef244e ipn, ipn/ipnlocal: add Foreground field to ServeConfig
This PR adds a new field to the ServeConfig which maps
WatchIPNBus session ids to foreground serve configs.

The PR also adds a DeleteForegroundSession method to ensure the config
gets cleaned up on sessions ending.

Note this field is not currently used but will be in follow up work.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-09-05 15:10:11 -04:00
Maisem Ali
cb3b281e98 ipn/ipnlocal: fix race in enterState
It would acquire the lock, calculate `nextState`, relase
the lock, then call `enterState` which would acquire the lock
again. There were obvious races there which could lead to
nil panics as seen in a test in a different repo.

```
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x70 pc=0x1050f2c7c]

goroutine 42240 [running]:
tailscale.com/ipn/ipnlocal.(*LocalBackend).enterStateLockedOnEntry(0x14002154e00, 0x6)
        tailscale.com/ipn/ipnlocal/local.go:3715 +0x30c
tailscale.com/ipn/ipnlocal.(*LocalBackend).enterState(0x14002154e00?, 0x14002e3a140?)
        tailscale.com/ipn/ipnlocal/local.go:3663 +0x8c
tailscale.com/ipn/ipnlocal.(*LocalBackend).stateMachine(0x14001f5e280?)
        tailscale.com/ipn/ipnlocal/local.go:3836 +0x2c
tailscale.com/ipn/ipnlocal.(*LocalBackend).setWgengineStatus(0x14002154e00, 0x14002e3a190, {0x0?, 0x0?})
        tailscale.com/ipn/ipnlocal/local.go:1193 +0x4d0
tailscale.com/wgengine.(*userspaceEngine).RequestStatus(0x14005d90300)
        tailscale.com/wgengine/userspace.go:1051 +0x80
tailscale.com/wgengine.NewUserspaceEngine.func2({0x14002e3a0a0, 0x2, 0x140025cce40?})
        tailscale.com/wgengine/userspace.go:318 +0x1a0
tailscale.com/wgengine/magicsock.(*Conn).updateEndpoints(0x14002154700, {0x105c13eaf, 0xf})
        tailscale.com/wgengine/magicsock/magicsock.go:531 +0x424
created by tailscale.com/wgengine/magicsock.(*Conn).ReSTUN in goroutine 42077
        tailscale.com/wgengine/magicsock/magicsock.go:2142 +0x3a4
```

Updates tailscale/corp#14480

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-05 10:45:11 -07:00
Marwan Sulaiman
a4aa6507fa ipn, ipn/ipnlocal: add session identifier for WatchIPNBus
This PR adds a SessionID field to the ipn.Notify struct so that
ipn buses can identify a session and register deferred clean up
code in the future. The first use case this is for is to be able to
tie foreground serve configs to a specific watch session and ensure
its clean up when a connection is closed.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-09-05 13:30:04 -04:00
Brad Fitzpatrick
7175f06e62 util/rands: add package with HexString func
We use it a number of places in different repos. Might as well make
one. Another use is coming.

Updates #cleanup

Change-Id: Ib7ce38de0db35af998171edee81ca875102349a4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-05 09:17:21 -07:00
Sonia Appasamy
f824274093 cli/serve: shorten help text on error
Our BETA serve help text is long and often hides the actual error
in the user's usage. Instead of printing the full text, prompt
users to use `serve --help` if they want the help info.

Fixes #14274

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-09-05 11:30:18 -04:00
Maisem Ali
3280c81c95 .github,cmd/gitops-pusher: update to checkout@v4
checkout@v3 is broken:
actions/checkout#1448

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-04 15:12:57 -07:00
Maisem Ali
0f397baf77 cmd/testwrapper: emit logs of failed tests on timeout
It would just fail the entire pkg, but would not print any
logs. It was already tracking all the logs, so have it emit
them when the pkg fails/times out.

Updates #9231

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-04 15:12:28 -07:00
Maisem Ali
52a19b5970 ipn/ipnlocal: prevent cc leaks on multiple Start calls
If Start was called multiple times concurrently, it would
create a new client and shutdown the previous one. However
there was a race possible between shutting down the old one
and assigning a new one where the concurent goroutine may
have assigned another one already and it would leak.

Updates tailscale/corp#14471

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-04 14:06:57 -07:00
Maisem Ali
6bc15f3a73 ipn/ipnlocal: fix startIsNoopLocked
It got broken back when FUS was introduced, but we
never caught it.

Updates tailscale/corp#14471

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-04 14:06:57 -07:00
Brad Fitzpatrick
1262df0578 net/netmon, net/tsdial: add some link change metrics
Updates #9040

Change-Id: I2c87572d79d2118bcf1f0122eccfe712c1bea9d5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-02 14:38:34 -07:00
Craig Rodrigues
8683ce78c2 client/web, clientupdate, util/linuxfw, wgengine/magicsock: Use %v verb for errors
Replace %w verb with %v verb when logging errors.
Use %w only for wrapping errors with fmt.Errorf()

Fixes: #9213

Signed-off-by: Craig Rodrigues <rodrigc@crodrigues.org>
2023-09-02 14:06:48 -07:00
Maisem Ali
d06a75dcd0 ipn/ipnlocal: fix deadlock in resetControlClientLocked
resetControlClientLocked is called while b.mu was held and
would call cc.Shutdown which would wait for the observer queue
to drain.
However, there may be active callbacks from cc already waiting for
b.mu resulting in a deadlock.

This makes it so that resetControlClientLocked does not call
Shutdown, and instead just returns the value.
It also makes it so that any status received from previous cc
are ignored.

Updates tailscale/corp#12827

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-09-02 13:47:32 -07:00
Joe Tsai
c6fadd6d71 all: implement AppendText alongside MarshalText (#9207)
This eventually allows encoding packages that may respect
the proposed encoding.TextAppender interface.
The performance gains from this is between 10-30%.

Updates tailscale/corp#14379

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-09-01 18:15:19 -07:00
Will Norris
9a3bc9049c client/web,cmd/tailscale: add prefix flag for web command
We already had a path on the web client server struct, but hadn't
plumbed it through to the CLI. Add that now and use it for Synology and
QNAP instead of hard-coding the path. (Adding flag for QNAP is
tailscale/tailscale-qpkg#112) This will allow supporting other
environments (like unraid) without additional changes to the client/web
package.

Also fix a small bug in unraid handling to only include the csrf token
on POST requests.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-09-01 14:29:36 -07:00
Andrew Lytvynov
34e3450734 cmd/tailscale,ipn: add auto-update flags and prefs (#8861)
The flags are hidden for now. Adding propagation to tailscaled and
persistence only. The prefs field is wrapped in a struct to allow for
future expansion (like update schedule).

Updates #6907

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-09-01 13:45:12 -07:00
Brad Fitzpatrick
055fdb235f cmd/tailscaled, tstest/integration: make tailscaled die when parent dies
I noticed that failed tests were leaving aroudn stray tailscaled processes
on macOS at least.

To repro, add this to tstest/integration:

    func TestFailInFewSeconds(t *testing.T) {
        t.Parallel()
        time.Sleep(3 * time.Second)
        os.Exit(1)
        t.Fatal("boom")
    }

Those three seconds let the other parallel tests (with all their
tailscaled child processes) start up and start running their tests,
but then we violently os.Exit(1) the test driver and all the children
were kept alive (and were spinning away, using all available CPU in
gvisor scheduler code, which is a separate scary issue)

Updates #cleanup

Change-Id: I9c891ed1a1ec639fb2afec2808c04dbb8a460e0e
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-01 12:32:47 -07:00
Tyler Smalley
e1fbb5457b cmd/tailscale: combine serve and funnel for debug wip funnel stream model (#9169)
> **Note**
> Behind the `TAILSCALE_USE_WIP_CODE` flag

In preparing for incoming CLI changes, this PR merges the code path for the `serve` and `funnel` subcommands.

See the parent issue for more context.

The following commands will run in foreground mode when using the environment flag.
```
tailscale serve localhost:3000
tailscae funnel localhost:3000
```

Replaces #9134
Updates #8489

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
Co-authored-by: Marwan Sulaiman <marwan@tailscale.com>
2023-09-01 12:28:29 -07:00
Brad Fitzpatrick
003e4aff71 control/controlclient: clean up various things in prep for state overhaul
We want the overall state (used only for tests) to be computed from
the individual states of each component, rather than moving the state
around by hand in dozens of places.

In working towards that, we found a lot of things to clean up.

Updates #cleanup

Change-Id: Ieaaae5355dfae789a8ec7a56ce212f1d7e3a92db
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-01 12:23:34 -07:00
Brad Fitzpatrick
0c1e3ff625 ipn/ipnlocal: avoid calling Start from resetForProfileChangeLockedOnEntry
During Shutdown of an ephemeral node, we called Logout (to best effort
delete the node earlier), which then called back into
resetForProfileChangeLockedOnEntry, which then tried to Start
again. That's all a waste of work during shutdown and complicates
other cleanups coming later.

Updates #cleanup

Change-Id: I0b8648cac492fc70fa97c4ebef919bbe352c5d7b
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-01 11:18:26 -07:00
Brad Fitzpatrick
9cbec4519b control/controlclient: serialize Observer calls
Don't just start goroutines and hope for them to be ordered.

Fixes potential regression from earlier 7074a40c0.

Updates #cleanup

Change-Id: I501a6f3e4e8e6306b958bccdc1e47869991c31f7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-01 10:25:37 -07:00
Brad Fitzpatrick
8b3ea13af0 net/tsdial: be smarter about when to close SystemDial conns
It was too aggressive before, as it only had the ill-defined "Major"
bool to work with. Now it can check more precisely.

Updates #9040

Change-Id: I20967283b64af6a9cad3f8e90cff406de91653b8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-09-01 09:33:55 -07:00
Brad Fitzpatrick
f7b7ccf835 control/controlclient, ipn/ipnlocal: unplumb a bool true literal opt
Updates #cleanup

Change-Id: I664f280a2e06b9875942458afcaf6be42a5e462a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-31 22:34:21 -07:00
Brad Fitzpatrick
346445acdd .github/workflows: only run bench all on packages with benchmarks
Drops time by several minutes.

Also, on top of that: skip building variant CLIs on the race builder
(29s), and getting qemu (15s).

Updates #9182

Change-Id: I979e02ab8c0daeebf5200459c9e4458a1f62f728
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-31 21:52:18 -07:00
Maisem Ali
96277b63ff ipn/ipnlocal: rename LogoutSync to Logout
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-31 21:29:12 -07:00
Maisem Ali
f52273767f ipn/ipnlocal: fix missing mutex usage for profileManager
It required holding b.mu but was documented incorrectly, fix.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-31 21:29:12 -07:00
Maisem Ali
959362a1f4 ipn/ipnlocal,control/controlclient: make Logout more sync
We already removed the async API, make it more sync and remove
the FinishLogout state too.

This also makes the callback be synchronous again as the previous
attempt was trying to work around the logout callback resulting
in a client shutdown getting blocked forever.

Updates #3833

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-31 21:29:12 -07:00
Maisem Ali
1f12b3aedc .github: do not use testwrapper for benchmarks
Updates #9182

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-31 15:23:41 -07:00
Maisem Ali
7074a40c06 control/controlclient: run SetControlClientStatus in goroutine
We have cases where the SetControlClientStatus would result in
a Shutdown call back into the auto client that would block
forever. The right thing to do here is to fix the LocalBackend
state machine but thats a different dumpster fire that we
are slowly making progress towards.

This makes it so that the SetControlClientStatus happens in a
different goroutine so that calls back into the auto client
do not block.

Also add a few missing mu.Unlocks in LocalBackend.Start.

Updates #9181

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-31 14:54:02 -07:00
Brad Fitzpatrick
86dc0af5ae control/controlclient: rename Auto cancel methods, add missing Lock variant
Then use the Locked variants in Shutdown while we already hold the lock.

Updates #cleanup

Change-Id: I367d53e6be6f37f783c8f43fc9c4d498d0adf501
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-31 14:53:48 -07:00
Brad Fitzpatrick
61ae16cb6f ipn/ipnlocal: add missing mutex unlock in error path
Found while debugging something else.

Updates #cleanup

Change-Id: I73fe55da14bcc3b1ffc39e2dbc0d077bc7f70cf1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-31 14:34:03 -07:00
Brad Fitzpatrick
47cf836720 tsnet: remove redundant ephemeral logout on close
LocalBackend.Shutdown already does it.

Updates #cleanup

Change-Id: Ie5dd7d8e5d9e69644f211ee1de6c790f57f5ae25
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-31 09:45:30 -07:00
Brad Fitzpatrick
21247f766f ipn/ipnlocal: deflake some tests
* don't try to re-Start (and thus create a new client) during Shutdown
* in tests, wait for controlclient to fully shut down when replacing it
* log a bit more

Updates tailscale/corp#14139
Updates tailscale/corp#13175 etc
Updates #9178 and its flakes.

Change-Id: I3ed2440644dc157aa6e616fe36fbd29a6056846c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-31 09:34:39 -07:00
Brad Fitzpatrick
04e1ce0034 control/controlclient: remove unused StartLogout
Updates #cleanup

Co-authored-by: Maisem Ali <maisem@tailscale.com>
Change-Id: I9d052fdbee787f1e8c872124e4bee61c7f04d142
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-30 20:23:03 -07:00
Brad Fitzpatrick
ecc1d6907b types/logger: add TestLogger
We have this in another repo and I wanted it here too.

Updates #cleanup

Change-Id: If93dc73f11eaaada5024acf2a885a153b88db5a0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-30 20:23:03 -07:00
Flakes Updater
77060c4d89 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-08-30 16:26:57 -07:00
Andrew Lytvynov
4e72992900 clientupdate: add linux tarball updates (#9144)
As a fallback to package managers, allow updating tailscale that was
self-installed in some way. There are some tricky bits around updating
the systemd unit (should we stick to local binary paths or to the ones
in tailscaled.service?), so leaving that out for now.

Updates #6995

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-30 17:25:06 -06:00
Chris Palmer
ce1e02096a ipn/ipnlocal: support most Linuxes in handleC2NUpdate (#9114)
* ipn/ipnlocal: support most Linuxes in handleC2NUpdate

Updates #6995

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
2023-08-30 14:50:03 -07:00
Brad Fitzpatrick
c621141746 control/controlclient: cancel map poll when logging out
Don't depend on the server to do it.

Updates #cleanup

Change-Id: I8ff40b02aa877155a71fd4db58cbecb872241ac8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-30 13:46:54 -07:00
Brad Fitzpatrick
313a129fe5 control/controlclient: use slices package more
Updates #cleanup

Change-Id: Ic17384266dc59bc4e710efdda311d6e0719529da
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-30 13:45:20 -07:00
Will Norris
37eab31f68 client/web: simply csrf key caching in cgi mode
Instead of trying to use the user config dir, and then fail back to the
OS temp dir, just always use the temp dir. Also use a filename that is
less likely to cause collisions.

This addresses an issue on a test synology instance that was
mysteriously failing because there was a file at /tmp/tailscale. We
could still technically run into this issue if a
/tmp/tailscale-web-csrf.key file exists, but that seems far less likely.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-30 11:49:09 -07:00
Brad Fitzpatrick
f5bfdefa00 control/controlclient: de-pointer Status.PersistView, document more
Updates #cleanup
Updates #1909

Change-Id: I31d91e120e3b299508de2136021eab3b34131a44
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-30 11:48:58 -07:00
Brad Fitzpatrick
7053e19562 control/controlclient: delete Status.Log{in,out}Finished
They were entirely redundant and 1:1 with the status field
so this turns them into methods instead.

Updates #cleanup
Updates #1909

Change-Id: I7d939750749edf7dae4c97566bbeb99f2f75adbc
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-30 11:21:06 -07:00
Maisem Ali
794650fe50 cmd/k8s-operator: emit event if HTTPS is disabled on Tailnet
Instead of confusing users, emit an event that explicitly tells the
user that HTTPS is disabled on the tailnet and that ingress may not
work until they enable it.

Updates #9141

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-30 10:53:40 -07:00
Denton Gentry
be9914f714 cmd/sniproxy: move default debug-port away from 8080.
Port 8080 is routinely used for HTTP services, make it easier to
use --forwards=tcp/8080/... by moving the metrics port out of the
way.

Updates #1748

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-30 10:52:22 -07:00
Brad Fitzpatrick
9ce1f5c7d2 control/controlclient: unexport Status.state, add test-only accessor
Updates #cleanup
Updates #1909

Change-Id: I38dcde6fa0de0f58ede4529992cee2e36de33dd6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-30 10:40:05 -07:00
Maisem Ali
306b85b9a3 cmd/k8s-operator: add metrics to track usage
Updates #502

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-30 10:33:54 -07:00
Andrea Barisani
0a74d46568 adjust build tags for tamago
Signed-off-by: Andrea Barisani <andrea@inversepath.com>
2023-08-30 09:14:54 -07:00
Brad Fitzpatrick
14320290c3 control/controlclient: merge, simplify two health check calls
I'm trying to remove some stuff from the netmap update path.

Updates #1909

Change-Id: Iad2c728dda160cd52f33ef9cf0b75b4940e0ce64
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-30 09:14:01 -07:00
Irbe Krumina
17438a98c0 cm/k8s-operator,cmd/containerboot: fix STS config, more tests (#9155)
Ensures that Statefulset reconciler config has only one of Cluster target IP or tailnet target IP.
Adds a test case for containerboot egress proxy mode.

Updates tailscale/tailscale#8184

Signed-off-by: irbekrm <irbekrm@gmail.com>
2023-08-30 14:22:06 +01:00
Denton Gentry
29a35d4a5d cmd/sniproxy: switch to peterbourgon/ff for flags
Add support for TS_APPC_* variables to supply arguments by
switching to https://github.com/peterbourgon/ff for CLI
flag parsing. For example:
TS_APPC_FORWARDS=tcp/22/github.com ./sniproxy

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

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-30 06:05:40 -07:00
Irbe Krumina
fe709c81e5 cmd/k8s-operator,cmd/containerboot: add kube egress proxy (#9031)
First part of work for the functionality that allows users to create an egress
proxy to access Tailnet services from within Kubernetes cluster workloads.
This PR allows creating an egress proxy that can access Tailscale services over HTTP only.

Updates tailscale/tailscale#8184

Signed-off-by: irbekrm <irbekrm@gmail.com>
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Co-authored-by: Rhea Ghosh <rhea@tailscale.com>
2023-08-30 08:31:37 +01:00
Maisem Ali
ae747a2e48 cmd/testwrapper: handle timeouts as test failures
While investigating the fix in 7538f38671,
I was curious why the testwrapper didn't fail. Turns out if the test
times out and there was no explicit failure, the only message we get
is that the overall pkg failed and no failure information about the
individual test. This resulted in a 0 exit code.

This fixes that by failing the explicit case of the pkg failing when
there is nothing to retry for that pkg.

Updates #8493

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-29 19:11:41 -07:00
Maisem Ali
b90b9b4653 client/web: fix data race
Fixes #9150

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-29 19:00:20 -07:00
Maisem Ali
7538f38671 cmd/containerboot: fix broken tests
The tests were broken in a61a9ab087, maybe
even earlier.

Updates #502

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-29 17:41:12 -07:00
Andrew Lytvynov
abfe5d3879 clientupdate: detect when tailscale is installed without package manager (#9137)
On linux users can install Tailscale via package managers or direct
tarball downloads. Detect when Tailscale is not installed via a package
manager so we can pick the correct update mechanism. Leave the tarball
update function unimplemented for now (coming in next PR!).

Updates #6995
Updates #8760

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-29 17:36:05 -07:00
David Anderson
8b492b4121 net/wsconn: accept a remote addr string and plumb it through
This makes wsconn.Conns somewhat present reasonably when they are
the client of an http.Request, rather than just put a placeholder
in that field.

Updates tailscale/corp#13777

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-08-29 16:57:16 -07:00
Sonia Appasamy
e952564b59 client/web: pipe unraid csrf token through apiFetch
Ensures that we're sending back the csrf token for all requests
made back to unraid clients.

Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-29 18:55:52 -04:00
Sonia Appasamy
1cd03bc0a1 client/web: remove self node on server
This is unused. Can be added back if needed in the future.

Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-29 17:55:09 -04:00
Sonia Appasamy
da6eb076aa client/web: add localapi proxy
Adds proxy to the localapi from /api/local/ web client endpoint.
The localapi proxy is restricted to an allowlist of those actually
used by the web client frontend.

Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-29 17:54:59 -04:00
Maisem Ali
c919ff540f cmd/k8s-operator,ipn/store/kubestore: patch secrets instead of updating
We would call Update on the secret, but that was racey and would occasionaly
fail. Instead use patch whenever we can.

Fixes errors like
```
boot: 2023/08/29 01:03:53 failed to set serve config: sending serve config: updating config: writing ServeConfig to StateStore: Operation cannot be fulfilled on secrets "ts-webdav-kfrzv-0": the object has been modified; please apply your changes to the latest version and try again

{"level":"error","ts":"2023-08-29T01:03:48Z","msg":"Reconciler error","controller":"ingress","controllerGroup":"networking.k8s.io","controllerKind":"Ingress","Ingress":{"name":"webdav","namespace":"default"},"namespace":"default","name":"webdav","reconcileID":"96f5cfed-7782-4834-9b75-b0950fd563ed","error":"failed to provision: failed to create or get API key secret: Operation cannot be fulfilled on secrets \"ts-webdav-kfrzv-0\": the object has been modified; please apply your changes to the latest version and try again","stacktrace":"sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).reconcileHandler\n\tsigs.k8s.io/controller-runtime@v0.15.0/pkg/internal/controller/controller.go:324\nsigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).processNextWorkItem\n\tsigs.k8s.io/controller-runtime@v0.15.0/pkg/internal/controller/controller.go:265\nsigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).Start.func2.2\n\tsigs.k8s.io/controller-runtime@v0.15.0/pkg/internal/controller/controller.go:226"}
```

Updates #502
Updates #7895

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-29 13:24:05 -07:00
Joe Tsai
930e6f68f2 types/opt: use switch in Bool.UnmarshalJSON (#9140)
The compiler does indeed perform this optimization.

Updates #cleanup

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-08-29 13:12:49 -07:00
Brad Fitzpatrick
11ece02f52 net/{interfaces,netmon}: remove "interesting", EqualFiltered API
This removes a lot of API from net/interfaces (including all the
filter types, EqualFiltered, active Tailscale interface func, etc) and
moves the "major" change detection to net/netmon which knows more
about the world and the previous/new states.

Updates #9040

Change-Id: I7fe66a23039c6347ae5458745b709e7ebdcce245
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-29 11:57:30 -07:00
Brad Fitzpatrick
6dfa403e6b cmd/tailscaled: default to userspace-networking on plan9
No tun support yet.

Updates #5794

Change-Id: Ibd8db67594d4c65b47e352ae2af2ab3d2712dfad
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-29 11:46:33 -07:00
Sonia Appasamy
7aea219a0f client/web: pull SynoToken logic into apiFetch
Updates tailscale/corp#13775
2023-08-29 14:27:38 -04:00
Brad Fitzpatrick
6b882a1511 control/controlclient: clean up a few little things
De-pointer a *time.Time type, move it after the mutex which guard is,
rename two test-only methods with our conventional "ForTest" suffix.

Updates #cleanup

Change-Id: I4f4d1acd9c2de33d9c3cb6465d7349ed051aa9f9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-29 08:17:23 -07:00
Brad Fitzpatrick
3bce9632d9 derp/derphttp: fix data race and crash in proxy dial error path
Named result meant error paths assigned that variable to nil.
But a goroutine was concurrently using that variable.

Don't use a named result for that first parameter. Then then return
paths don't overwrite it.

Fixes #9129

Change-Id: Ie57f99d40ca8110085097780686d9bd620aaf160
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-29 07:56:54 -07:00
197 changed files with 8608 additions and 2523 deletions

View File

@@ -45,7 +45,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -10,6 +10,6 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: "Build Docker image"
run: docker build .

View File

@@ -17,7 +17,7 @@ jobs:
id-token: "write"
contents: "read"
steps:
- uses: "actions/checkout@v3"
- uses: "actions/checkout@v4"
with:
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
- uses: "DeterminateSystems/nix-installer-action@main"

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4

View File

@@ -23,7 +23,7 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install govulncheck
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest

View File

@@ -91,7 +91,7 @@ jobs:
|| contains(matrix.image, 'parrotsec')
|| contains(matrix.image, 'kalilinux')
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: run installer
run: scripts/installer.sh
# Package installation can fail in docker because systemd is not running

View File

@@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache@v3
with:
@@ -74,6 +74,7 @@ jobs:
env:
GOARCH: ${{ matrix.goarch }}
- name: build variant CLIs
if: matrix.buildflags == '' # skip on race builder
run: |
export TS_USE_TOOLCHAIN=1
./build_dist.sh --extra-small ./cmd/tailscaled
@@ -83,7 +84,7 @@ jobs:
env:
GOARCH: ${{ matrix.goarch }}
- name: get qemu # for tstest/archtest
if: matrix.goarch == 'amd64' && matrix.variant == ''
if: matrix.goarch == 'amd64' && matrix.buildflags == ''
run: |
sudo apt-get -y update
sudo apt-get -y install qemu-user
@@ -94,7 +95,7 @@ jobs:
env:
GOARCH: ${{ matrix.goarch }}
- name: bench all
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
env:
GOARCH: ${{ matrix.goarch }}
- name: check that no tracked files changed
@@ -115,7 +116,7 @@ jobs:
runs-on: windows-2022
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v4
@@ -141,10 +142,12 @@ jobs:
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-go-2-
- name: test
run: go run ./cmd/testwrapper ./...
- name: bench all
# Don't use -bench=. -benchtime=1x.
# Somewhere in the layers (powershell?)
# the equals signs cause great confusion.
run: go run ./cmd/testwrapper ./... -bench . -benchtime 1x
run: go test ./... -bench . -benchtime 1x -run "^$"
vm:
runs-on: ["self-hosted", "linux", "vm"]
@@ -152,7 +155,7 @@ jobs:
if: github.repository == 'tailscale/tailscale'
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Run VM tests
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
env:
@@ -201,7 +204,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache@v3
with:
@@ -238,7 +241,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: build some
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
env:
@@ -252,7 +255,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
# some Android breakages early.
@@ -267,7 +270,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache@v3
with:
@@ -301,7 +304,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: test tailscale_go
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
@@ -369,7 +372,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: check depaware
run: |
export PATH=$(./tool/go env GOROOT)/bin:$PATH
@@ -379,7 +382,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: check that 'go generate' is clean
run: |
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
@@ -392,7 +395,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: check that 'go mod tidy' is clean
run: |
./tool/go mod tidy
@@ -404,7 +407,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: check licenses
run: ./scripts/check_license_headers.sh .
@@ -420,7 +423,7 @@ jobs:
goarch: "386"
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: install staticcheck
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
- name: run staticcheck

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Run update-flakes
run: ./update-flake.sh

View File

@@ -41,6 +41,16 @@ 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.)
To include the embedded web client (accessed via the `tailscale web` command),
first build the client assets using:
```
./tool/yarn --cwd client/web install
./tool/yarn --cwd client/web build
```
Build the `tailscale` and `tailscaled` binaries:
```
go install tailscale.com/cmd/tailscale{,d}
```
@@ -57,17 +67,6 @@ If your distro has conventions that preclude the use of
`build_dist.sh`, please do the equivalent of what it does in your
distro's way, so that bug reports contain useful version information.
## Building the web client
To include the embedded web client (accessed via the `tailscale web` command),
you'll need to build the client assets using:
```
./tool/yarn --cwd client/web build
```
Do this before building the `tailscale.com/cmd/tailscale` binary.
## Bugs
Please file any issues about this code or the hosted service on

View File

@@ -392,6 +392,20 @@ func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
return nil
}
// DebugResultJSON invokes a debug action and returns its result as something JSON-able.
// These are development tools and subject to change or removal over time.
func (lc *LocalClient) DebugResultJSON(ctx context.Context, action string) (any, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
var x any
if err := json.Unmarshal(body, &x); err != nil {
return nil, err
}
return x, nil
}
// DebugPortmapOpts contains options for the DebugPortmap command.
type DebugPortmapOpts struct {
// Duration is how long the mapping should be created for. It defaults
@@ -1094,29 +1108,6 @@ func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) er
return nil
}
// StreamServe returns an io.ReadCloser that streams serve/Funnel
// connections made to the provided HostPort.
//
// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig,
// the backend enables it for the duration of the context's lifespan and
// then turns it back off once the context is closed. If either are already enabled,
// then they remain that way but logs are still streamed
func (lc *LocalClient) StreamServe(ctx context.Context, hp ipn.ServeStreamRequest) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/stream-serve", jsonBody(hp))
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
res.Body.Close()
return nil, errors.New(res.Status)
}
return res.Body, nil
}
// GetServeConfig return the current serve config.
//
// If the serve config is empty, it returns (nil, nil).

View File

@@ -4,6 +4,8 @@
package web
import (
"embed"
"io/fs"
"log"
"net/http"
"net/http/httputil"
@@ -12,11 +14,42 @@ import (
"os/exec"
"path/filepath"
"strings"
"tailscale.com/util/must"
)
// This contains all files needed to build the frontend assets.
// Because we assign this to the blank identifier, it does not actually embed the files.
// However, this does cause `go mod vendor` to include the files when vendoring the package.
// External packages that use the web client can `go mod vendor`, run `yarn build` to
// build the assets, then those asset bundles will be embedded.
//
//go:embed yarn.lock index.html *.js *.json src/*
var _ embed.FS
//go:embed build/*
var embeddedFS embed.FS
// staticfiles serves static files from the build directory.
var staticfiles http.Handler
func init() {
buildFiles := must.Get(fs.Sub(embeddedFS, "build"))
staticfiles = http.FileServer(http.FS(buildFiles))
}
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
if devMode {
// When in dev mode, proxy asset requests to the Vite dev server.
cleanup := startDevServer()
return devServerProxy(), cleanup
}
return staticfiles, nil
}
// 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()) {
func startDevServer() (cleanup func()) {
root := gitRootDir()
webClientPath := filepath.Join(root, "client", "web")
@@ -45,10 +78,8 @@ func (s *Server) startDevServer() (cleanup func()) {
}
}
func (s *Server) addProxyToDevServer() {
if !s.devMode {
return // only using Vite proxy in dev mode
}
// devServerProxy returns a reverse proxy to the vite dev server.
func devServerProxy() *httputil.ReverseProxy {
// 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.
@@ -62,8 +93,9 @@ func (s *Server) addProxyToDevServer() {
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
devProxy := httputil.NewSingleHostReverseProxy(viteTarget)
devProxy.ErrorHandler = handleErr
return devProxy
}
func gitRootDir() string {

View File

@@ -6,7 +6,7 @@
<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" />
<script type="module" crossorigin src="./assets/index-f8beba53.js"></script>
<script type="module" crossorigin src="./assets/index-4d1f45ea.js"></script>
<link rel="stylesheet" href="./assets/index-8612dca6.css">
</head>
<body>

View File

@@ -16,8 +16,6 @@ import (
"net/url"
)
const qnapPrefix = "/cgi-bin/qpkg/Tailscale/index.cgi/"
// authorizeQNAP authenticates the logged-in QNAP user and verifies
// that they are authorized to use the web client. It returns true if the
// request was handled and no further processing is required.

View File

@@ -1,14 +1,49 @@
let csrfToken: string
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
// apiFetch wraps the standard JS fetch function
// with csrf header management.
// apiFetch wraps the standard JS fetch function with csrf header
// management and param additions specific to the web client.
//
// apiFetch adds the `api` prefix to the request URL,
// so endpoint should be provided without the `api` prefix
// (i.e. provide `/data` rather than `api/data`).
export function apiFetch(
input: RequestInfo | URL,
init?: RequestInit | undefined
endpoint: string,
method: "GET" | "POST",
body?: any,
params?: Record<string, string>
): Promise<Response> {
return fetch(input, {
...init,
headers: withCsrfToken(init?.headers),
const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams(params)
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const search = nextParams.toString()
const url = `api${endpoint}${search ? `?${search}` : ""}`
var contentType: string
if (unraidCsrfToken && method === "POST") {
const params = new URLSearchParams()
params.append("csrf_token", unraidCsrfToken)
if (body) {
params.append("ts_data", JSON.stringify(body))
}
body = params.toString()
contentType = "application/x-www-form-urlencoded;charset=UTF-8"
} else {
body = body ? JSON.stringify(body) : undefined
contentType = "application/json"
}
return fetch(url, {
method: method,
headers: {
Accept: "application/json",
"Content-Type": contentType,
"X-CSRF-Token": csrfToken,
},
body,
}).then((r) => {
updateCsrfToken(r)
if (!r.ok) {
@@ -20,13 +55,13 @@ export function apiFetch(
})
}
function withCsrfToken(h?: HeadersInit): HeadersInit {
return { ...h, "X-CSRF-Token": csrfToken }
}
function updateCsrfToken(r: Response) {
const tok = r.headers.get("X-CSRF-Token")
if (tok) {
csrfToken = tok
}
}
export function setUnraidCsrfToken(token?: string) {
unraidCsrfToken = token
}

View File

@@ -5,7 +5,7 @@ import useNodeData from "src/hooks/node-data"
export default function App() {
// TODO(sonia): use isPosting value from useNodeData
// to fill loading states.
const { data, updateNode } = useNodeData()
const { data, refreshData, updateNode } = useNodeData()
return (
<div className="py-14">
@@ -15,7 +15,11 @@ export default function App() {
) : (
<>
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} updateNode={updateNode} />
<Header
data={data}
refreshData={refreshData}
updateNode={updateNode}
/>
<IP data={data} />
<State data={data} updateNode={updateNode} />
</main>

View File

@@ -1,5 +1,6 @@
import cx from "classnames"
import React from "react"
import { apiFetch } from "src/api"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
@@ -9,9 +10,11 @@ import { NodeData, NodeUpdate } from "src/hooks/node-data"
export function Header({
data,
refreshData,
updateNode,
}: {
data: NodeData
refreshData: () => void
updateNode: (update: NodeUpdate) => void
}) {
return (
@@ -89,7 +92,11 @@ export function Header({
</button>{" "}
|{" "}
<button
onClick={() => updateNode({ ForceLogout: true })}
onClick={() =>
apiFetch("/local/v0/logout", "POST")
.then(refreshData)
.catch((err) => alert("Logout failed: " + err.message))
}
className="hover:text-gray-700"
>
Logout

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch } from "src/api"
import { apiFetch, setUnraidCsrfToken } from "src/api"
export type NodeData = {
Profile: UserProfile
@@ -35,21 +35,17 @@ export default function useNodeData() {
const [data, setData] = useState<NodeData>()
const [isPosting, setIsPosting] = useState<boolean>(false)
const fetchNodeData = useCallback(() => {
const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams()
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const search = nextParams.toString()
const url = `api/data${search ? `?${search}` : ""}`
apiFetch(url)
.then((r) => r.json())
.then((d) => setData(d))
.catch((error) => console.error(error))
}, [setData])
const refreshData = useCallback(
() =>
apiFetch("/data", "GET")
.then((r) => r.json())
.then((d: NodeData) => {
setData(d)
setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined)
})
.catch((error) => console.error(error)),
[setData]
)
const updateNode = useCallback(
(update: NodeUpdate) => {
@@ -77,33 +73,7 @@ export default function useNodeData() {
: data.AdvertiseExitNode,
}
const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams({ up: "true" })
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const search = nextParams.toString()
const url = `api/data${search ? `?${search}` : ""}`
var body, contentType: string
if (data.IsUnraid) {
const params = new URLSearchParams()
params.append("csrf_token", data.UnraidToken)
params.append("ts_data", JSON.stringify(update))
body = params.toString()
contentType = "application/x-www-form-urlencoded;charset=UTF-8"
} else {
body = JSON.stringify(update)
contentType = "application/json"
}
apiFetch(url, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": contentType },
body: body,
})
apiFetch("/data", "POST", update, { up: "true" })
.then((r) => r.json())
.then((r) => {
setIsPosting(false)
@@ -115,7 +85,7 @@ export default function useNodeData() {
if (url) {
window.open(url, "_blank")
}
fetchNodeData()
refreshData()
})
.catch((err) => alert("Failed operation: " + err.message))
},
@@ -125,11 +95,11 @@ export default function useNodeData() {
useEffect(
() => {
// Initial data load.
fetchNodeData()
refreshData()
// Refresh on browser tab focus.
const onVisibilityChange = () => {
document.visibilityState === "visible" && fetchNodeData()
document.visibilityState === "visible" && refreshData()
}
window.addEventListener("visibilitychange", onVisibilityChange)
return () => {
@@ -141,5 +111,5 @@ export default function useNodeData() {
[]
)
return { data, updateNode, isPosting }
return { data, refreshData, updateNode, isPosting }
}

View File

@@ -15,8 +15,6 @@ import (
"tailscale.com/util/groupmember"
)
const synologyPrefix = "/webman/3rdparty/Tailscale/index.cgi/"
// authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client. It returns true if the
// request was handled and no further processing is required.

View File

@@ -7,22 +7,20 @@ package web
import (
"context"
"crypto/rand"
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"net/http/httputil"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"github.com/gorilla/csrf"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
@@ -30,47 +28,20 @@ import (
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
"tailscale.com/util/must"
"tailscale.com/version/distro"
)
// This contains all files needed to build the frontend assets.
// Because we assign this to the blank identifier, it does not actually embed the files.
// However, this does cause `go mod vendor` to include the files when vendoring the package.
// External packages that use the web client can `go mod vendor`, run `yarn build` to
// build the assets, then those asset bundles will be embedded.
//
//go:embed yarn.lock index.html *.js *.json src/*
var _ embed.FS
//go:embed build/*
var embeddedFS embed.FS
// staticfiles serves static files from the build directory.
var staticfiles http.Handler
// 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
devMode bool
cgiMode bool
cgiPath string
apiHandler http.Handler // csrf-protected api handler
pathPrefix string
selfMu sync.Mutex // protects self field
// self is a cached NodeView of the active self node,
// refreshed by watching the IPN notification bus
// (see Server.watchSelf).
//
// self's hostname and Tailscale IP are used to verify
// that incoming requests to the web client api are coming
// from the web client frontend and not some other source.
// Particularly to protect against DNS rebinding attacks.
// self should not be used to fill data for frontend views.
self tailcfg.NodeView
assetsHandler http.Handler // serves frontend assets
apiHandler http.Handler // serves api endpoints; csrf-protected
}
// ServerOpts contains options for constructing a new Server.
@@ -80,8 +51,8 @@ type ServerOpts struct {
// CGIMode indicates if the server is running as a CGI script.
CGIMode bool
// If running in CGIMode, CGIPath is the URL path prefix to the CGI script.
CGIPath string
// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
PathPrefix string
// LocalClient is the tailscale.LocalClient to use for this web server.
// If nil, a new one will be created.
@@ -95,24 +66,12 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
opts.LocalClient = &tailscale.LocalClient{}
}
s = &Server{
devMode: opts.DevMode,
lc: opts.LocalClient,
cgiMode: opts.CGIMode,
cgiPath: opts.CGIPath,
devMode: opts.DevMode,
lc: opts.LocalClient,
cgiMode: opts.CGIMode,
pathPrefix: opts.PathPrefix,
}
cleanup = func() {}
if s.devMode {
cleanup = s.startDevServer()
s.addProxyToDevServer()
}
var wg sync.WaitGroup
defer wg.Wait()
wg.Add(1)
go func() {
defer wg.Done()
go s.watchSelf(ctx)
}()
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
// Create handler for "/api" requests with CSRF protection.
// We don't require secure cookies, since the web client is regularly used
@@ -126,81 +85,13 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
return s, cleanup
}
func init() {
buildFiles := must.Get(fs.Sub(embeddedFS, "build"))
staticfiles = http.FileServer(http.FS(buildFiles))
}
// watchSelf watches the IPN notification bus to refresh
// the Server's self node cache.
func (s *Server) watchSelf(ctx context.Context) {
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
watcher, err := s.lc.WatchIPNBus(watchCtx, ipn.NotifyInitialNetMap|ipn.NotifyNoPrivateKeys)
if err != nil {
log.Fatalf("lost connection to tailscaled: %v", err)
}
defer watcher.Close()
for {
n, err := watcher.Next()
if err != nil {
log.Fatalf("lost connection to tailscaled: %v", err)
}
if state := n.State; state != nil && *state == ipn.NeedsLogin {
s.updateSelf(tailcfg.NodeView{})
continue
}
if n.NetMap == nil {
continue
}
s.updateSelf(n.NetMap.SelfNode)
}
}
// updateSelf grabs the lock and updates s.self.
// Then logs if anything changed.
func (s *Server) updateSelf(self tailcfg.NodeView) {
s.selfMu.Lock()
prev := s.self
s.self = self
s.selfMu.Unlock()
var old, new tailcfg.StableNodeID
if prev.Valid() {
old = prev.StableID()
}
if s.self.Valid() {
new = s.self.StableID()
}
if old != new {
if new.IsZero() {
log.Printf("self node logout")
} else {
log.Printf("self node login")
}
}
}
// ServeHTTP processes all requests for the Tailscale web client.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler := s.serve
// if running in cgi mode, strip the cgi path prefix
if s.cgiMode {
prefix := s.cgiPath
if prefix == "" {
switch distro.Get() {
case distro.Synology:
prefix = synologyPrefix
case distro.QNAP:
prefix = qnapPrefix
}
}
if prefix != "" {
handler = enforcePrefix(prefix, handler)
}
// if path prefix is defined, strip it from requests.
if s.pathPrefix != "" {
handler = enforcePrefix(s.pathPrefix, handler)
}
handler(w, r)
@@ -233,14 +124,11 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
// Pass API requests through to the API handler.
s.apiHandler.ServeHTTP(w, r)
return
case s.devMode:
// When in dev mode, proxy non-api requests to the Vite dev server.
s.devProxy.ServeHTTP(w, r)
return
default:
// Otherwise, serve static files from the embedded filesystem.
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
staticfiles.ServeHTTP(w, r)
if !s.devMode {
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
}
s.assetsHandler.ServeHTTP(w, r)
return
}
}
@@ -251,8 +139,8 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api")
switch path {
case "/data":
switch {
case path == "/data":
switch r.Method {
case httpm.GET:
s.serveGetNodeDataJSON(w, r)
@@ -262,6 +150,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
case strings.HasPrefix(path, "/local/"):
s.proxyRequestToLocalAPI(w, r)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
}
@@ -402,7 +293,6 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
} else {
io.WriteString(w, "{}")
}
return
}
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) {
@@ -464,21 +354,70 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData
}
}
// proxyRequestToLocalAPI proxies the web API request to the localapi.
//
// The web API request path is expected to exactly match a localapi path,
// with prefix /api/local/ rather than /localapi/.
//
// If the localapi path is not included in localapiAllowlist,
// the request is rejected.
func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/local")
if r.URL.Path == path { // missing prefix
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if !slices.Contains(localapiAllowlist, path) {
http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
return
}
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
if err != nil {
http.Error(w, "failed to construct request", http.StatusInternalServerError)
return
}
// Make request to tailscaled localapi.
resp, err := s.lc.DoLocalRequest(req)
if err != nil {
http.Error(w, err.Error(), resp.StatusCode)
return
}
defer resp.Body.Close()
// Send response back to web frontend.
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// localapiAllowlist is an allowlist of localapi endpoints the
// web client is allowed to proxy to the client's localapi.
//
// Rather than exposing all localapi endpoints over the proxy,
// this limits to just the ones actually used from the web
// client frontend.
//
// TODO(sonia,will): Shouldn't expand this beyond the existing
// localapi endpoints until the larger web client auth story
// is worked out (tailscale/corp#14335).
var localapiAllowlist = []string{
"/v0/logout",
}
// csrfKey returns a key that can be used for CSRF protection.
// If an error occurs during key creation, the error is logged and the active process terminated.
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
// If an error occurs during key storage, the error is logged and the active process terminated.
func (s *Server) csrfKey() []byte {
var csrfFile string
csrfFile := filepath.Join(os.TempDir(), "tailscale-web-csrf.key")
// if running in CGI mode, try to read from disk, but ignore errors
if s.cgiMode {
confdir, err := os.UserConfigDir()
if err != nil {
confdir = os.TempDir()
}
csrfFile = filepath.Join(confdir, "tailscale", "web-csrf.key")
key, _ := os.ReadFile(csrfFile)
if len(key) == 32 {
return key
@@ -488,14 +427,11 @@ func (s *Server) csrfKey() []byte {
// create a new key
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
log.Fatal("error generating CSRF key: %w", err)
log.Fatalf("error generating CSRF key: %v", err)
}
// if running in CGI mode, try to write the newly created key to disk, and exit if it fails.
if s.cgiMode {
if err := os.Mkdir(filepath.Dir(csrfFile), 0700); err != nil && !os.IsExist(err) {
log.Fatalf("unable to store CSRF key: %v", err)
}
if err := os.WriteFile(csrfFile, key, 0600); err != nil {
log.Fatalf("unable to store CSRF key: %v", err)
}
@@ -509,6 +445,19 @@ func (s *Server) csrfKey() []byte {
// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present.
// Instead, it returns a redirect to the prefix path.
func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc {
if prefix == "" {
return h
}
// ensure that prefix always has both a leading and trailing slash so
// that relative links for JS and CSS assets work correctly.
if !strings.HasPrefix(prefix, "/") {
prefix = "/" + prefix
}
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
return func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, prefix) {
http.Redirect(w, r, prefix, http.StatusFound)

View File

@@ -4,8 +4,16 @@
package web
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"tailscale.com/client/tailscale"
"tailscale.com/net/memnet"
)
func TestQnapAuthnURL(t *testing.T) {
@@ -62,3 +70,62 @@ func TestQnapAuthnURL(t *testing.T) {
})
}
}
// TestServeAPI tests the web client api's handling of
// 1. invalid endpoint errors
// 2. localapi proxy allowlist
func TestServeAPI(t *testing.T) {
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
// Serve dummy localapi. Just returns "success".
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "success")
})}
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
tests := []struct {
name string
reqPath string
wantResp string
wantStatus int
}{{
name: "invalid_endpoint",
reqPath: "/not-an-endpoint",
wantResp: "invalid endpoint",
wantStatus: http.StatusNotFound,
}, {
name: "not_in_localapi_allowlist",
reqPath: "/local/v0/not-allowlisted",
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
wantStatus: http.StatusForbidden,
}, {
name: "in_localapi_allowlist",
reqPath: "/local/v0/logout",
wantResp: "success", // Successfully allowed to hit localapi.
wantStatus: http.StatusOK,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/api"+tt.reqPath, nil)
w := httptest.NewRecorder()
s.serveAPI(w, r)
res := w.Result()
defer res.Body.Close()
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
t.Errorf("wrong status; want=%q, got=%q", tt.wantStatus, gotStatus)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
if tt.wantResp != gotResp {
t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, gotResp)
}
})
}
}

View File

@@ -7,13 +7,16 @@
package clientupdate
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"os"
"os/exec"
@@ -57,14 +60,8 @@ func versionToTrack(v string) (string, error) {
return "unstable", nil
}
type updater struct {
UpdateArgs
track string
update func() error
}
// UpdateArgs contains arguments needed to run an update.
type UpdateArgs struct {
// Arguments contains arguments needed to run an update.
type Arguments struct {
// Version can be a specific version number or one of the predefined track
// constants:
//
@@ -76,7 +73,7 @@ type UpdateArgs struct {
// 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.
// not installed via an app store. TODO(cpalmer): Remove this.
AppStore bool
// Logf is a logger for update progress messages.
Logf logger.Logf
@@ -89,30 +86,31 @@ type UpdateArgs struct {
PkgsAddr string
}
func (args UpdateArgs) validate() error {
func (args Arguments) validate() error {
if args.Confirm == nil {
return errors.New("missing Confirm callback in UpdateArgs")
return errors.New("missing Confirm callback in Arguments")
}
if args.Logf == nil {
return errors.New("missing Logf callback in UpdateArgs")
return errors.New("missing Logf callback in Arguments")
}
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
type Updater struct {
Arguments
track string
// Update is a platform-specific method that updates the installation. May be
// nil (not all platforms support updates from within Tailscale).
Update func() error
}
func NewUpdater(args Arguments) (*Updater, error) {
up := Updater{
Arguments: args,
}
if args.PkgsAddr == "" {
args.PkgsAddr = "https://pkgs.tailscale.com"
}
up := &updater{
UpdateArgs: args,
up.Update = up.getUpdateFunction()
if up.Update == nil {
return nil, errors.ErrUnsupported
}
switch up.Version {
case StableTrack, UnstableTrack:
@@ -127,56 +125,82 @@ func Update(args UpdateArgs) error {
var err error
up.track, err = versionToTrack(args.Version)
if err != nil {
return err
return nil, err
}
}
if up.Arguments.PkgsAddr == "" {
up.Arguments.PkgsAddr = "https://pkgs.tailscale.com"
}
return &up, nil
}
type updateFunction func() error
func (up *Updater) getUpdateFunction() updateFunction {
switch runtime.GOOS {
case "windows":
up.update = up.updateWindows
return up.updateWindows
case "linux":
switch distro.Get() {
case distro.Synology:
up.update = up.updateSynology
return up.updateSynology
case distro.Debian: // includes Ubuntu
up.update = up.updateDebLike
return up.updateDebLike
case distro.Arch:
up.update = up.updateArchLike
return up.updateArchLike
case distro.Alpine:
up.update = up.updateAlpineLike
return up.updateAlpineLike
}
switch {
case haveExecutable("pacman"):
up.update = up.updateArchLike
return 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
return up.updateDebLike
case haveExecutable("dnf"):
up.update = up.updateFedoraLike("dnf")
return up.updateFedoraLike("dnf")
case haveExecutable("yum"):
up.update = up.updateFedoraLike("yum")
return up.updateFedoraLike("yum")
case haveExecutable("apk"):
up.update = up.updateAlpineLike
return up.updateAlpineLike
}
// If nothing matched, fall back to tarball updates.
if up.Update == nil {
return up.updateLinuxBinary
}
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
case !up.Arguments.AppStore && !version.IsSandboxedMacOS():
return nil
case !up.Arguments.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
return up.updateMacSys
default:
up.update = up.updateMacAppStore
return up.updateMacAppStore
}
case "freebsd":
up.update = up.updateFreeBSD
return up.updateFreeBSD
}
if up.update == nil {
return errors.ErrUnsupported
}
return up.update()
return nil
}
func (up *updater) confirm(ver string) bool {
// 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 Arguments) error {
if err := args.validate(); err != nil {
return err
}
up, err := NewUpdater(args)
if err != nil {
return err
}
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
@@ -189,13 +213,14 @@ func (up *updater) confirm(ver string) bool {
const synoinfoConfPath = "/etc/synoinfo.conf"
func (up *updater) updateSynology() error {
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())
dsmVersion := distro.DSMVersion()
osName := fmt.Sprintf("dsm%d", dsmVersion)
arch, err := synoArch(runtime.GOARCH, synoinfoConfPath)
if err != nil {
return err
@@ -236,8 +261,20 @@ func (up *updater) updateSynology() error {
// just spits out a JSON result when done.
out, err := cmd.CombinedOutput()
if err != nil {
if dsmVersion == 6 && bytes.Contains(out, []byte("error = [290]")) {
return fmt.Errorf("synopkg install failed: %w\noutput:\n%s\nplease make sure that packages from 'Any publisher' are allowed in the Package Center (Package Center -> Settings -> Trust Level -> Any publisher)", err, out)
}
return fmt.Errorf("synopkg install failed: %w\noutput:\n%s", err, out)
}
if dsmVersion == 6 {
// DSM6 does not automatically restart the package on install. Do it
// manually.
cmd := exec.Command("nohup", "synopkg", "start", "Tailscale")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("synopkg start failed: %w\noutput:\n%s", err, out)
}
}
return nil
}
@@ -299,7 +336,15 @@ func parseSynoinfo(path string) (string, error) {
return "", fmt.Errorf(`missing "unique=" field in %q`, path)
}
func (up *updater) updateDebLike() error {
func (up *Updater) updateDebLike() error {
if err := requireRoot(); err != nil {
return err
}
if err := exec.Command("dpkg", "--status", "tailscale").Run(); err != nil && isExitError(err) {
// Tailscale was not installed via apt, update via tarball download
// instead.
return up.updateLinuxBinary()
}
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
@@ -308,10 +353,6 @@ func (up *updater) updateDebLike() error {
return nil
}
if err := requireRoot(); err != nil {
return err
}
if updated, err := updateDebianAptSourcesList(up.track); err != nil {
return err
} else if updated {
@@ -401,7 +442,12 @@ func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []
return buf.Bytes(), nil
}
func (up *updater) updateArchLike() error {
func (up *Updater) updateArchLike() error {
if err := exec.Command("pacman", "--query", "tailscale").Run(); err != nil && isExitError(err) {
// Tailscale was not installed via pacman, update via tarball download
// instead.
return up.updateLinuxBinary()
}
// Arch maintainer asked us not to implement "tailscale update" or
// auto-updates on Arch-based distros:
// https://github.com/tailscale/tailscale/issues/6995#issuecomment-1687080106
@@ -414,11 +460,16 @@ 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 {
func (up *Updater) updateFedoraLike(packageManager string) func() error {
return func() (err error) {
if err := requireRoot(); err != nil {
return err
}
if err := exec.Command(packageManager, "info", "--installed", "tailscale").Run(); err != nil && isExitError(err) {
// Tailscale was not installed via yum/dnf, update via tarball
// download instead.
return up.updateLinuxBinary()
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "%s upgrade tailscale"`, err, packageManager)
@@ -490,13 +541,18 @@ func updateYUMRepoTrack(repoFile, dstTrack string) (rewrote bool, err error) {
return true, os.WriteFile(repoFile, newContent.Bytes(), 0644)
}
func (up *updater) updateAlpineLike() (err error) {
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
}
if err := exec.Command("apk", "info", "--installed", "tailscale").Run(); err != nil && isExitError(err) {
// Tailscale was not installed via apk, update via tarball download
// instead.
return up.updateLinuxBinary()
}
defer func() {
if err != nil {
@@ -547,11 +603,11 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
return "", errors.New("tailscale version not found in output")
}
func (up *updater) updateMacSys() error {
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 {
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))
@@ -612,7 +668,7 @@ var (
markTempFileFunc func(string) error // or nil on non-Windows
)
func (up *updater) updateWindows() error {
func (up *Updater) updateWindows() error {
if msi := os.Getenv(winMSIEnv); msi != "" {
up.Logf("installing %v ...", msi)
if err := up.installMSI(msi); err != nil {
@@ -682,7 +738,7 @@ func (up *updater) updateWindows() error {
panic("unreachable")
}
func (up *updater) installMSI(msi string) error {
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")
@@ -749,7 +805,7 @@ func makeSelfCopy() (tmpPathExe string, err error) {
return f2.Name(), f2.Close()
}
func (up *updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
c, err := distsign.NewClient(up.Logf, up.PkgsAddr)
if err != nil {
return err
@@ -757,13 +813,18 @@ func (up *updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
return c.Download(context.Background(), pathSrc, fileDst)
}
func (up *updater) updateFreeBSD() (err error) {
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
}
if err := exec.Command("pkg", "query", "%n", "tailscale").Run(); err != nil && isExitError(err) {
// Tailscale was not installed via pkg and we don't pre-compile
// binaries for it.
return errors.New("Tailscale was not installed via pkg, binary updates on FreeBSD are not supported; please reinstall Tailscale using pkg or update manually")
}
defer func() {
if err != nil {
@@ -793,6 +854,165 @@ func (up *updater) updateFreeBSD() (err error) {
return nil
}
func (up *Updater) updateLinuxBinary() error {
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
// Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil {
return err
}
dlPath, err := up.downloadLinuxTarball(ver)
if err != nil {
return err
}
up.Logf("Extracting %q", dlPath)
if err := up.unpackLinuxTarball(dlPath); err != nil {
return err
}
if err := os.Remove(dlPath); err != nil {
up.Logf("failed to clean up %q: %v", dlPath, err)
}
if err := restartSystemdUnit(context.Background()); err != nil {
if errors.Is(err, errors.ErrUnsupported) {
up.Logf("Tailscale binaries updated successfully.\nPlease restart tailscaled to finish the update.")
} else {
up.Logf("Tailscale binaries updated successfully, but failed to restart tailscaled: %s.\nPlease restart tailscaled to finish the update.", err)
}
} else {
up.Logf("Success")
}
return nil
}
func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
dlDir, err := os.UserCacheDir()
if err != nil {
return "", err
}
dlDir = filepath.Join(dlDir, "tailscale-update")
if err := os.MkdirAll(dlDir, 0700); err != nil {
return "", err
}
pkgsPath := fmt.Sprintf("%s/tailscale_%s_%s.tgz", up.track, ver, runtime.GOARCH)
dlPath := filepath.Join(dlDir, path.Base(pkgsPath))
if err := up.downloadURLToFile(pkgsPath, dlPath); err != nil {
return "", err
}
return dlPath, nil
}
func (up *Updater) unpackLinuxTarball(path string) error {
tailscale, tailscaled, err := binaryPaths()
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gr.Close()
tr := tar.NewReader(gr)
files := make(map[string]int)
wantFiles := map[string]int{
"tailscale": 1,
"tailscaled": 1,
}
for {
th, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed extracting %q: %w", path, err)
}
// TODO(awly): try to also extract tailscaled.service. The tricky part
// is fixing up binary paths in that file if they differ from where
// local tailscale/tailscaled are installed. Also, this may not be a
// systemd distro.
switch filepath.Base(th.Name) {
case "tailscale":
files["tailscale"]++
if err := writeFile(tr, tailscale+".new", 0755); err != nil {
return fmt.Errorf("failed extracting the new tailscale binary from %q: %w", path, err)
}
case "tailscaled":
files["tailscaled"]++
if err := writeFile(tr, tailscaled+".new", 0755); err != nil {
return fmt.Errorf("failed extracting the new tailscaled binary from %q: %w", path, err)
}
}
}
if !maps.Equal(files, wantFiles) {
return fmt.Errorf("%q has missing or duplicate files: got %v, want %v", path, files, wantFiles)
}
// Only place the files in final locations after everything extracted correctly.
if err := os.Rename(tailscale+".new", tailscale); err != nil {
return err
}
up.Logf("Updated %s", tailscale)
if err := os.Rename(tailscaled+".new", tailscaled); err != nil {
return err
}
up.Logf("Updated %s", tailscaled)
return nil
}
func writeFile(r io.Reader, path string, perm os.FileMode) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove existing file at %q: %w", path, err)
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, r); err != nil {
return err
}
return f.Close()
}
// Var allows overriding this in tests.
var binaryPaths = func() (tailscale, tailscaled string, err error) {
// This can be either tailscale or tailscaled.
this, err := os.Executable()
if err != nil {
return "", "", err
}
otherName := "tailscaled"
if filepath.Base(this) == "tailscaled" {
otherName = "tailscale"
}
// Try to find the other binary in the same directory.
other := filepath.Join(filepath.Dir(this), otherName)
_, err = os.Stat(other)
if os.IsNotExist(err) {
// If it's not in the same directory, try to find it in $PATH.
other, err = exec.LookPath(otherName)
}
if err != nil {
return "", "", fmt.Errorf("cannot find %q in neither %q nor $PATH: %w", otherName, filepath.Dir(this), err)
}
if otherName == "tailscaled" {
return this, other, nil
} else {
return other, this, nil
}
}
func haveExecutable(name string) bool {
path, err := exec.LookPath(name)
return err == nil && path != ""
@@ -867,3 +1087,8 @@ func requireRoot() error {
return errors.New("must be root")
}
}
func isExitError(err error) bool {
var exitErr *exec.ExitError
return errors.As(err, &exitErr)
}

View File

@@ -4,9 +4,14 @@
package clientupdate
import (
"archive/tar"
"compress/gzip"
"fmt"
"io/fs"
"maps"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -502,3 +507,257 @@ unique="synology_88f6281_213air"
})
}
}
func TestUnpackLinuxTarball(t *testing.T) {
oldBinaryPaths := binaryPaths
t.Cleanup(func() { binaryPaths = oldBinaryPaths })
tests := []struct {
desc string
tarball map[string]string
before map[string]string
after map[string]string
wantErr bool
}{
{
desc: "success",
before: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
},
tarball: map[string]string{
"/usr/bin/tailscale": "v2",
"/usr/bin/tailscaled": "v2",
},
after: map[string]string{
"tailscale": "v2",
"tailscaled": "v2",
},
},
{
desc: "don't touch unrelated files",
before: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
"foo": "bar",
},
tarball: map[string]string{
"/usr/bin/tailscale": "v2",
"/usr/bin/tailscaled": "v2",
},
after: map[string]string{
"tailscale": "v2",
"tailscaled": "v2",
"foo": "bar",
},
},
{
desc: "unmodified",
before: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
},
tarball: map[string]string{
"/usr/bin/tailscale": "v1",
"/usr/bin/tailscaled": "v1",
},
after: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
},
},
{
desc: "ignore extra tarball files",
before: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
},
tarball: map[string]string{
"/usr/bin/tailscale": "v2",
"/usr/bin/tailscaled": "v2",
"/systemd/tailscaled.service": "v2",
},
after: map[string]string{
"tailscale": "v2",
"tailscaled": "v2",
},
},
{
desc: "tarball missing tailscaled",
before: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
},
tarball: map[string]string{
"/usr/bin/tailscale": "v2",
},
after: map[string]string{
"tailscale": "v1",
"tailscale.new": "v2",
"tailscaled": "v1",
},
wantErr: true,
},
{
desc: "duplicate tailscale binary",
before: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
},
tarball: map[string]string{
"/usr/bin/tailscale": "v2",
"/usr/sbin/tailscale": "v2",
"/usr/bin/tailscaled": "v2",
},
after: map[string]string{
"tailscale": "v1",
"tailscale.new": "v2",
"tailscaled": "v1",
"tailscaled.new": "v2",
},
wantErr: true,
},
{
desc: "empty archive",
before: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
},
tarball: map[string]string{},
after: map[string]string{
"tailscale": "v1",
"tailscaled": "v1",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
// Swap out binaryPaths function to point at dummy file paths.
tmp := t.TempDir()
tailscalePath := filepath.Join(tmp, "tailscale")
tailscaledPath := filepath.Join(tmp, "tailscaled")
binaryPaths = func() (string, string, error) {
return tailscalePath, tailscaledPath, nil
}
for name, content := range tt.before {
if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0755); err != nil {
t.Fatal(err)
}
}
tarPath := filepath.Join(tmp, "tailscale.tgz")
genTarball(t, tarPath, tt.tarball)
up := &Updater{Arguments: Arguments{Logf: t.Logf}}
err := up.unpackLinuxTarball(tarPath)
if err != nil {
if !tt.wantErr {
t.Fatalf("unexpected error: %v", err)
}
} else if tt.wantErr {
t.Fatalf("unpack succeeded, expected an error")
}
gotAfter := make(map[string]string)
err = filepath.WalkDir(tmp, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsDir() {
return nil
}
if path == tarPath {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
path = filepath.ToSlash(path)
base := filepath.ToSlash(tmp)
gotAfter[strings.TrimPrefix(path, base+"/")] = string(content)
return nil
})
if err != nil {
t.Fatal(err)
}
if !maps.Equal(gotAfter, tt.after) {
t.Errorf("files after unpack: %+v, want %+v", gotAfter, tt.after)
}
})
}
}
func genTarball(t *testing.T, path string, files map[string]string) {
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gw := gzip.NewWriter(f)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
for file, content := range files {
if err := tw.WriteHeader(&tar.Header{
Name: file,
Size: int64(len(content)),
Mode: 0755,
}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
}
func TestWriteFileOverwrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "test")
for i := 0; i < 2; i++ {
content := fmt.Sprintf("content %d", i)
if err := writeFile(strings.NewReader(content), path, 0600); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != content {
t.Errorf("got content: %q, want: %q", got, content)
}
}
}
func TestWriteFileSymlink(t *testing.T) {
// Test for a malicious symlink at the destination path.
// f2 points to f1 and writeFile(f2) should not end up overwriting f1.
tmp := t.TempDir()
f1 := filepath.Join(tmp, "f1")
if err := os.WriteFile(f1, []byte("old"), 0600); err != nil {
t.Fatal(err)
}
f2 := filepath.Join(tmp, "f2")
if err := os.Symlink(f1, f2); err != nil {
t.Fatal(err)
}
if err := writeFile(strings.NewReader("new"), f2, 0600); err != nil {
t.Errorf("writeFile(%q) failed: %v", f2, err)
}
want := map[string]string{
f1: "old",
f2: "new",
}
for f, content := range want {
got, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
if string(got) != content {
t.Errorf("%q: got content %q, want %q", f, got, content)
}
}
}

View File

@@ -247,6 +247,48 @@ func (c *Client) Download(ctx context.Context, srcPath, dstPath string) error {
return nil
}
// ValidateLocalBinary fetches the latest signature associated with the binary
// at srcURLPath and uses it to validate the file located on disk via
// localFilePath. ValidateLocalBinary returns an error if anything goes wrong
// with the signature download or with signature validation.
func (c *Client) ValidateLocalBinary(srcURLPath, localFilePath string) error {
// Always fetch a fresh signing key.
sigPub, err := c.signingKeys()
if err != nil {
return err
}
srcURL := c.url(srcURLPath)
sigURL := srcURL + ".sig"
localFile, err := os.Open(localFilePath)
if err != nil {
return err
}
defer localFile.Close()
h := NewPackageHash()
_, err = io.Copy(h, localFile)
if err != nil {
return err
}
hash, hashLen := h.Sum(nil), h.Len()
c.logf("Downloading %q", sigURL)
sig, err := fetch(sigURL, signatureSizeLimit)
if err != nil {
return err
}
msg := binary.LittleEndian.AppendUint64(hash, uint64(hashLen))
if !VerifyAny(sigPub, msg, sig) {
return fmt.Errorf("signature %q for file %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, localFilePath)
}
c.logf("Signature OK")
return nil
}
// signingKeys fetches current signing keys from the server and validates them
// against the roots. Should be called before validation of any downloaded file
// to get the fresh keys.

View File

@@ -119,6 +119,121 @@ func TestDownload(t *testing.T) {
}
}
func TestValidateLocalBinary(t *testing.T) {
srv := newTestServer(t)
c := srv.client(t)
tests := []struct {
desc string
before func(*testing.T)
src string
wantErr bool
}{
{
desc: "missing file",
before: func(*testing.T) {},
src: "hello",
wantErr: true,
},
{
desc: "success",
before: func(*testing.T) {
srv.addSigned("hello", []byte("world"))
},
src: "hello",
},
{
desc: "contents changed",
before: func(*testing.T) {
srv.addSigned("hello", []byte("new world"))
},
src: "hello",
wantErr: true,
},
{
desc: "no signature",
before: func(*testing.T) {
srv.add("hello", []byte("world"))
},
src: "hello",
wantErr: true,
},
{
desc: "bad signature",
before: func(*testing.T) {
srv.add("hello", []byte("world"))
srv.add("hello.sig", []byte("potato"))
},
src: "hello",
wantErr: true,
},
{
desc: "signed with untrusted key",
before: func(t *testing.T) {
srv.add("hello", []byte("world"))
srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world")))
},
src: "hello",
wantErr: true,
},
{
desc: "signed with root key",
before: func(t *testing.T) {
srv.add("hello", []byte("world"))
srv.add("hello.sig", ed25519.Sign(srv.roots[0].k, []byte("world")))
},
src: "hello",
wantErr: true,
},
{
desc: "bad signing key signature",
before: func(t *testing.T) {
srv.add("distsign.pub.sig", []byte("potato"))
srv.addSigned("hello", []byte("world"))
},
src: "hello",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
srv.reset()
// First just do a successful Download.
want := []byte("world")
srv.addSigned("hello", want)
dst := filepath.Join(t.TempDir(), tt.src)
err := c.Download(context.Background(), tt.src, dst)
if err != nil {
t.Fatalf("unexpected error from Download(%q): %v", tt.src, err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(want, got) {
t.Errorf("Download(%q): got %q, want %q", tt.src, got, want)
}
// Now we reset srv with the test case and validate against the local dst.
srv.reset()
tt.before(t)
err = c.ValidateLocalBinary(tt.src, dst)
if err != nil {
if tt.wantErr {
return
}
t.Fatalf("unexpected error from ValidateLocalBinary(%q): %v", tt.src, err)
}
if tt.wantErr {
t.Fatalf("ValidateLocalBinary(%q) succeeded, expected an error", tt.src)
}
})
}
}
func TestRotateRoot(t *testing.T) {
srv := newTestServer(t)
c1 := srv.client(t)

View File

@@ -0,0 +1,3 @@
-----BEGIN ROOT PUBLIC KEY-----
Psrabv2YNiEDhPlnLVSMtB5EKACm7zxvKxfvYD4i7X8=
-----END ROOT PUBLIC KEY-----

View File

@@ -0,0 +1,37 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package clientupdate
import (
"context"
"errors"
"fmt"
"github.com/coreos/go-systemd/v22/dbus"
)
func restartSystemdUnit(ctx context.Context) error {
c, err := dbus.NewWithContext(ctx)
if err != nil {
// Likely not a systemd-managed distro.
return errors.ErrUnsupported
}
defer c.Close()
if err := c.ReloadContext(ctx); err != nil {
return fmt.Errorf("failed to reload tailsacled.service: %w", err)
}
ch := make(chan string, 1)
if _, err := c.RestartUnitContext(ctx, "tailscaled.service", "replace", ch); err != nil {
return fmt.Errorf("failed to restart tailsacled.service: %w", err)
}
select {
case res := <-ch:
if res != "done" {
return fmt.Errorf("systemd service restart failed with result %q", res)
}
case <-ctx.Done():
return ctx.Err()
}
return nil
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package clientupdate
import (
"context"
"errors"
)
func restartSystemdUnit(ctx context.Context) error {
return errors.ErrUnsupported
}

View File

@@ -16,6 +16,8 @@
// - TS_ROUTES: subnet routes to advertise.
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
// destination.
// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given
// destination.
// - TS_TAILSCALED_EXTRA_ARGS: extra arguments to 'tailscaled'.
// - TS_EXTRA_ARGS: extra arguments to 'tailscale login', these are not
// reset on restart.
@@ -88,8 +90,9 @@ func main() {
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnv("TS_ROUTES", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
@@ -107,16 +110,17 @@ func main() {
if cfg.ProxyTo != "" && cfg.UserspaceMode {
log.Fatal("TS_DEST_IP is not supported with TS_USERSPACE")
}
if cfg.ProxyTo != "" && cfg.ServeConfigPath != "" {
log.Fatal("TS_DEST_IP is not supported with TS_SERVE_CONFIG")
if cfg.TailnetTargetIP != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if !cfg.UserspaceMode {
if err := ensureTunFile(cfg.Root); err != nil {
log.Fatalf("Unable to create tuntap device file: %v", err)
}
if cfg.ProxyTo != "" || cfg.Routes != "" {
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.Routes); err != nil {
if cfg.ProxyTo != "" || cfg.Routes != "" || cfg.TailnetTargetIP != "" {
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.TailnetTargetIP, cfg.Routes); err != nil {
log.Printf("Failed to enable IP forwarding: %v", err)
log.Printf("To run tailscale as a proxy or router container, IP forwarding must be enabled.")
if cfg.InKubernetes {
@@ -270,7 +274,7 @@ authLoop:
}
var (
wantProxy = cfg.ProxyTo != ""
wantProxy = cfg.ProxyTo != "" || cfg.TailnetTargetIP != ""
wantDeviceInfo = cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch
startupTasksDone = false
currentIPs deephash.Sum // tailscale IPs assigned to device
@@ -297,9 +301,13 @@ authLoop:
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
}
if n.NetMap != nil {
if cfg.ProxyTo != "" && len(n.NetMap.Addresses) > 0 && deephash.Update(&currentIPs, &n.NetMap.Addresses) {
if err := installIPTablesRule(ctx, cfg.ProxyTo, n.NetMap.Addresses); err != nil {
log.Fatalf("installing proxy rules: %v", err)
addrs := n.NetMap.SelfNode.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs); err != nil {
log.Fatalf("installing ingress proxy rules: %v", err)
}
}
if cfg.ServeConfigPath != "" && len(n.NetMap.DNS.CertDomains) > 0 {
@@ -312,6 +320,13 @@ authLoop:
}
}
}
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) > 0 {
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs); err != nil {
log.Fatalf("installing egress proxy rules: %v", err)
}
}
currentIPs = newCurrentIPs
deviceInfo := []any{n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name()}
if cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" && deephash.Update(&currentDeviceInfo, &deviceInfo) {
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
@@ -570,14 +585,25 @@ func ensureTunFile(root string) error {
}
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
func ensureIPForwarding(root, proxyTo, routes string) error {
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, routes string) error {
var (
v4Forwarding, v6Forwarding bool
)
if proxyTo != "" {
proxyIP, err := netip.ParseAddr(proxyTo)
if clusterProxyTarget != "" {
proxyIP, err := netip.ParseAddr(clusterProxyTarget)
if err != nil {
return fmt.Errorf("invalid proxy destination IP: %v", err)
return fmt.Errorf("invalid cluster destination IP: %v", err)
}
if proxyIP.Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
if tailnetTargetiP != "" {
proxyIP, err := netip.ParseAddr(tailnetTargetiP)
if err != nil {
return fmt.Errorf("invalid tailnet destination IP: %v", err)
}
if proxyIP.Is4() {
v4Forwarding = true
@@ -627,7 +653,53 @@ func ensureIPForwarding(root, proxyTo, routes string) error {
return nil
}
func installIPTablesRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
argv0 := "iptables"
if dst.Is6() {
argv0 = "ip6tables"
}
var local string
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr().String()
break
}
if local == "" {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
// Technically, if the control server ever changes the IPs assigned to this
// node, we'll slowly accumulate iptables rules. This shouldn't happen, so
// for now we'll live with it.
// Set up a rule that ensures that all packets
// except for those received on tailscale0 interface is forwarded to
// destination address
cmdDNAT := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "!", "-i", "tailscale0", "-j", "DNAT", "--to-destination", dstStr)
cmdDNAT.Stdout = os.Stdout
cmdDNAT.Stderr = os.Stderr
if err := cmdDNAT.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
}
// Set up a rule that ensures that all packets sent to the destination
// address will have the proxy's IP set as source IP
cmdSNAT := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "POSTROUTING", "1", "--destination", dstStr, "-j", "SNAT", "--to-source", local)
cmdSNAT.Stdout = os.Stdout
cmdSNAT.Stderr = os.Stderr
if err := cmdSNAT.Run(); err != nil {
return fmt.Errorf("setting up SNAT via iptables failed: %w", err)
}
return nil
}
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
@@ -664,10 +736,17 @@ func installIPTablesRule(ctx context.Context, dstStr string, tsIPs []netip.Prefi
// settings is all the configuration for containerboot.
type settings struct {
AuthKey string
Hostname string
Routes string
ProxyTo string
AuthKey string
Hostname string
Routes string
// ProxyTo is the destination IP to which all incoming
// Tailscale traffic should be proxied. If empty, no proxying
// is done. This is typically a locally reachable IP.
ProxyTo string
// TailnetTargetIP is the destination IP to which all incoming
// non-Tailscale traffic should be proxied. If empty, no
// proxying is done. This is typically a Tailscale IP.
TailnetTargetIP string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string

View File

@@ -134,11 +134,14 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -152,11 +155,14 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -170,11 +176,14 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -188,11 +197,14 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -206,7 +218,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -215,6 +227,9 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "0",
"proc/sys/net/ipv6/conf/all/forwarding": "0",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=1.2.3.0/24,10.20.30.0/24",
},
},
},
},
@@ -229,7 +244,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -238,6 +253,9 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "1",
"proc/sys/net/ipv6/conf/all/forwarding": "0",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=1.2.3.0/24,10.20.30.0/24",
},
},
},
},
@@ -252,7 +270,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1::/64",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -261,6 +279,9 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "0",
"proc/sys/net/ipv6/conf/all/forwarding": "1",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=::/64,1::/64",
},
},
},
},
@@ -275,7 +296,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1.2.3.0/24",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -284,11 +305,14 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "1",
"proc/sys/net/ipv6/conf/all/forwarding": "1",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=::/64,1.2.3.0/24",
},
},
},
},
{
Name: "proxy",
Name: "ingres proxy",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_DEST_IP": "1.2.3.4",
@@ -298,17 +322,42 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
"/usr/bin/iptables -t nat -I PREROUTING 1 -d 100.64.0.1 -j DNAT --to-destination 1.2.3.4",
},
},
},
},
{
Name: "egress proxy",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_TAILNET_TARGET_IP": "100.99.99.99",
"TS_USERSPACE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
"/usr/bin/iptables -t nat -I PREROUTING 1 ! -i tailscale0 -j DNAT --to-destination 100.99.99.99",
"/usr/bin/iptables -t nat -I POSTROUTING 1 --destination 100.99.99.99 -j SNAT --to-source 100.64.0.1",
},
},
},
},
{
Name: "authkey_once",
Env: map[string]string{
@@ -326,11 +375,14 @@ func TestContainerBoot(t *testing.T) {
State: ptr.To(ipn.NeedsLogin),
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -347,7 +399,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -355,6 +407,9 @@ func TestContainerBoot(t *testing.T) {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
@@ -379,12 +434,15 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{},
},
{
Notify: runningNotify,
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{},
},
},
@@ -402,12 +460,15 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{},
},
{
Notify: runningNotify,
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{},
},
},
@@ -437,7 +498,7 @@ func TestContainerBoot(t *testing.T) {
State: ptr.To(ipn.NeedsLogin),
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -445,6 +506,9 @@ func TestContainerBoot(t *testing.T) {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
@@ -466,7 +530,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -474,6 +538,9 @@ func TestContainerBoot(t *testing.T) {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
@@ -511,11 +578,14 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --socks5-server=localhost:1080 --outbound-http-proxy-listen=localhost:8080",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -528,11 +598,14 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=true",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=true",
},
},
},
},
@@ -546,10 +619,14 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --experiments=widgets",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --widget=rotated",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --widget=rotated",
},
}, {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -562,10 +639,14 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --hostname=my-server",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
},
}, {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --hostname=my-server",
},
},
},
},
@@ -606,7 +687,7 @@ func TestContainerBoot(t *testing.T) {
t.Fatalf("starting containerboot: %v", err)
}
defer func() {
cmd.Process.Signal(unix.SIGTERM)
cmd.Process.Signal(unix.SIGKILL)
cmd.Process.Wait()
}()
@@ -794,10 +875,17 @@ func (l *localAPI) Notify(n *ipn.Notify) {
}
func (l *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
panic(fmt.Sprintf("unsupported method %q", r.Method))
}
if r.URL.Path != "/localapi/v0/watch-ipn-bus" {
switch r.URL.Path {
case "/localapi/v0/serve-config":
if r.Method != "POST" {
panic(fmt.Sprintf("unsupported method %q", r.Method))
}
return
case "/localapi/v0/watch-ipn-bus":
if r.Method != "GET" {
panic(fmt.Sprintf("unsupported method %q", r.Method))
}
default:
panic(fmt.Sprintf("unsupported path %q", r.URL.Path))
}

View File

@@ -136,7 +136,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/types/structs from tailscale.com/ipn+
tailscale.com/types/tkatype from tailscale.com/types/key+
tailscale.com/types/views from tailscale.com/ipn/ipnstate+
W tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy
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+

View File

@@ -50,7 +50,7 @@ func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
return
}
counterWebSocketAccepts.Add(1)
wc := wsconn.NetConn(r.Context(), c, websocket.MessageBinary)
wc := wsconn.NetConn(r.Context(), c, websocket.MessageBinary, r.RemoteAddr)
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
s.Accept(r.Context(), wc, brw, r.RemoteAddr)
})

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup Go environment
uses: actions/setup-go@v3.2.0

View File

@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"strings"
"sync"
"go.uber.org/zap"
"golang.org/x/exp/slices"
@@ -21,6 +22,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/ipn"
"tailscale.com/types/opt"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
type IngressReconciler struct {
@@ -29,8 +32,20 @@ type IngressReconciler struct {
recorder record.EventRecorder
ssr *tailscaleSTSReconciler
logger *zap.SugaredLogger
mu sync.Mutex // protects following
// managedIngresses is a set of all ingress resources that we're currently
// managing. This is only used for metrics.
managedIngresses set.Slice[types.UID]
}
var (
// gaugeIngressResources tracks the number of ingress resources that we're
// currently managing.
gaugeIngressResources = clientmetric.NewGauge("k8s_ingress_resources")
)
func (a *IngressReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
logger := a.logger.With("ingress-ns", req.Namespace, "ingress-name", req.Name)
logger.Debugf("starting reconcile")
@@ -57,6 +72,10 @@ func (a *IngressReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
ix := slices.Index(ing.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
a.mu.Lock()
defer a.mu.Unlock()
a.managedIngresses.Remove(ing.UID)
gaugeIngressResources.Set(int64(a.managedIngresses.Len()))
return nil
}
@@ -77,6 +96,10 @@ func (a *IngressReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
// cleanup removes the tailscale finalizer, which will make all future
// reconciles exit early.
logger.Infof("unexposed ingress from tailnet")
a.mu.Lock()
defer a.mu.Unlock()
a.managedIngresses.Remove(ing.UID)
gaugeIngressResources.Set(int64(a.managedIngresses.Len()))
return nil
}
@@ -97,6 +120,14 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
return fmt.Errorf("failed to add finalizer: %w", err)
}
}
a.mu.Lock()
a.managedIngresses.Add(ing.UID)
gaugeIngressResources.Set(int64(a.managedIngresses.Len()))
a.mu.Unlock()
if !a.ssr.IsHTTPSEnabledOnTailnet() {
a.recorder.Event(ing, corev1.EventTypeWarning, "HTTPSNotEnabled", "HTTPS is not enabled on the tailnet; ingress may not work")
}
// magic443 is a fake hostname that we can use to tell containerboot to swap
// out with the real hostname once it's known.
@@ -190,7 +221,7 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
ChildResourceLabels: crl,
}
if err := a.ssr.Provision(ctx, logger, sts); err != nil {
if _, err := a.ssr.Provision(ctx, logger, sts); err != nil {
return fmt.Errorf("failed to provision: %w", err)
}

View File

@@ -73,7 +73,7 @@ func main() {
if shouldRunAuthProxy {
launchAuthProxy(zlog, restConfig, s)
}
startReconcilers(zlog, tsNamespace, restConfig, tsClient, image, priorityClassName, tags)
startReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags)
}
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
@@ -182,7 +182,7 @@ waitOnline:
// startReconcilers starts the controller-runtime manager and registers the
// ServiceReconciler.
func startReconcilers(zlog *zap.SugaredLogger, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags string) {
func startReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags string) {
var (
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
)
@@ -225,6 +225,7 @@ func startReconcilers(zlog *zap.SugaredLogger, tsNamespace string, restConfig *r
eventRecorder := mgr.GetEventRecorderFor("tailscale-operator")
ssr := &tailscaleSTSReconciler{
Client: mgr.GetClient(),
tsnetServer: s,
tsClient: tsClient,
defaultTags: strings.Split(tags, ","),
operatorNamespace: tsNamespace,

View File

@@ -7,6 +7,7 @@ package main
import (
"context"
"fmt"
"strings"
"sync"
"testing"
@@ -153,6 +154,111 @@ func TestLoadBalancerClass(t *testing.T) {
}
expectEqual(t, fc, want)
}
func TestTailnetTargetIPAnnotation(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
tailnetTargetIP := "100.66.66.66"
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetIP: tailnetTargetIP,
},
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: map[string]string{
"foo": "bar",
},
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedEgressSTS(shortName, fullName, tailnetTargetIP, "default-test", ""))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
Finalizers: []string{"tailscale.com/finalizer"},
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetIP: tailnetTargetIP,
},
},
Spec: corev1.ServiceSpec{
ExternalName: fmt.Sprintf("%s.operator-ns.svc", shortName),
Type: corev1.ServiceTypeExternalName,
Selector: nil,
},
}
expectEqual(t, fc, want)
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedEgressSTS(shortName, fullName, tailnetTargetIP, "default-test", ""))
// Change the tailscale-target-ip annotation which should update the
// StatefulSet
tailnetTargetIP = "100.77.77.77"
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.ObjectMeta.Annotations = map[string]string{
AnnotationTailnetTargetIP: tailnetTargetIP,
}
})
// Remove the tailscale-target-ip annotation which should make the
// operator clean up
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.ObjectMeta.Annotations = map[string]string{}
})
expectReconciled(t, sr, "default", "test")
// // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet
// // didn't create any child resources since this is all faked, so the
// // deletion goes through immediately.
expectReconciled(t, sr, "default", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
// // The deletion triggers another reconcile, to finish the cleanup.
expectReconciled(t, sr, "default", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
// At the moment we don't revert changes to the user created Service -
// we don't have a reliable way how to tell what it was before and also
// we don't really expect it to be re-used
}
func TestAnnotations(t *testing.T) {
fc := fake.NewFakeClient()
@@ -781,8 +887,8 @@ func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
"tailscale.com/operator-last-set-hostname": hostname,
"tailscale.com/operator-last-set-ip": "10.20.30.40",
"tailscale.com/operator-last-set-hostname": hostname,
"tailscale.com/operator-last-set-cluster-ip": "10.20.30.40",
},
DeletionGracePeriodSeconds: ptr.To[int64](10),
Labels: map[string]string{"app": "1234-UID"},
@@ -825,6 +931,75 @@ func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv
},
}
}
func expectedEgressSTS(stsName, secretName, tailnetTargetIP, hostname, priorityClassName string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
APIVersion: "apps/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: stsName,
Namespace: "operator-ns",
Labels: map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource": "test",
"tailscale.com/parent-resource-ns": "default",
"tailscale.com/parent-resource-type": "svc",
},
},
Spec: appsv1.StatefulSetSpec{
Replicas: ptr.To[int32](1),
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "1234-UID"},
},
ServiceName: stsName,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
"tailscale.com/operator-last-set-hostname": hostname,
"tailscale.com/operator-last-set-ts-tailnet-target-ip": tailnetTargetIP,
},
DeletionGracePeriodSeconds: ptr.To[int64](10),
Labels: map[string]string{"app": "1234-UID"},
},
Spec: corev1.PodSpec{
ServiceAccountName: "proxies",
PriorityClassName: priorityClassName,
InitContainers: []corev1.Container{
{
Name: "sysctler",
Image: "busybox",
Command: []string{"/bin/sh"},
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
SecurityContext: &corev1.SecurityContext{
Privileged: ptr.To(true),
},
},
},
Containers: []corev1.Container{
{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: secretName},
{Name: "TS_HOSTNAME", Value: hostname},
{Name: "TS_TAILNET_TARGET_IP", Value: tailnetTargetIP},
},
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{"NET_ADMIN"},
},
},
ImagePullPolicy: "Always",
},
},
},
},
},
}
}
func findGenName(t *testing.T, client client.Client, ns, name string) (full, noSuffix string) {
t.Helper()

View File

@@ -25,6 +25,7 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
@@ -42,6 +43,8 @@ func addWhoIsToRequest(r *http.Request, who *apitype.WhoIsResponse) *http.Reques
return r.WithContext(context.WithValue(r.Context(), whoIsKey{}, who))
}
var counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied")
// launchAuthProxy launches the auth proxy, which is a small HTTP server that
// authenticates requests using the Tailscale LocalAPI and then proxies them to
// the kube-apiserver.
@@ -84,6 +87,7 @@ func (h *authProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
return
}
counterNumRequestsProxied.Add(1)
h.rp.ServeHTTP(w, addWhoIsToRequest(r, who))
}

View File

@@ -24,6 +24,7 @@ import (
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/opt"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
@@ -38,17 +39,19 @@ const (
FinalizerName = "tailscale.com/finalizer"
// Annotations settable by users on services.
AnnotationExpose = "tailscale.com/expose"
AnnotationTags = "tailscale.com/tags"
AnnotationHostname = "tailscale.com/hostname"
AnnotationExpose = "tailscale.com/expose"
AnnotationTags = "tailscale.com/tags"
AnnotationHostname = "tailscale.com/hostname"
AnnotationTailnetTargetIP = "tailscale.com/ts-tailnet-target-ip"
// Annotations settable by users on ingresses.
AnnotationFunnel = "tailscale.com/funnel"
// Annotations set by the operator on pods to trigger restarts when the
// hostname or IP changes.
podAnnotationLastSetIP = "tailscale.com/operator-last-set-ip"
podAnnotationLastSetHostname = "tailscale.com/operator-last-set-hostname"
podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip"
podAnnotationLastSetHostname = "tailscale.com/operator-last-set-hostname"
podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip"
)
type tailscaleSTSConfig struct {
@@ -57,7 +60,11 @@ type tailscaleSTSConfig struct {
ChildResourceLabels map[string]string
ServeConfig *ipn.ServeConfig
TargetIP string
// Tailscale target in cluster we are setting up ingress for
ClusterTargetIP string
// Tailscale IP of a Tailscale service we are setting up egress for
TailnetTargetIP string
Hostname string
Tags []string // if empty, use defaultTags
@@ -65,6 +72,7 @@ type tailscaleSTSConfig struct {
type tailscaleSTSReconciler struct {
client.Client
tsnetServer *tsnet.Server
tsClient tsClient
defaultTags []string
operatorNamespace string
@@ -72,25 +80,30 @@ type tailscaleSTSReconciler struct {
proxyPriorityClassName string
}
// IsHTTPSEnabledOnTailnet reports whether HTTPS is enabled on the tailnet.
func (a *tailscaleSTSReconciler) IsHTTPSEnabledOnTailnet() bool {
return len(a.tsnetServer.CertDomains()) > 0
}
// Provision ensures that the StatefulSet for the given service is running and
// up to date.
func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) error {
func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
// Do full reconcile.
hsvc, err := a.reconcileHeadlessService(ctx, logger, sts)
if err != nil {
return fmt.Errorf("failed to reconcile headless service: %w", err)
return nil, fmt.Errorf("failed to reconcile headless service: %w", err)
}
secretName, err := a.createOrGetSecret(ctx, logger, sts, hsvc)
if err != nil {
return fmt.Errorf("failed to create or get API key secret: %w", err)
return nil, fmt.Errorf("failed to create or get API key secret: %w", err)
}
_, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName)
if err != nil {
return fmt.Errorf("failed to reconcile statefulset: %w", err)
return nil, fmt.Errorf("failed to reconcile statefulset: %w", err)
}
return nil
return hsvc, nil
}
// Cleanup removes all resources associated that were created by Provision with
@@ -175,15 +188,15 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
Labels: stsC.ChildResourceLabels,
},
}
alreadyExists := false
var orig *corev1.Secret // unmodified copy of secret
if err := a.Get(ctx, client.ObjectKeyFromObject(secret), secret); err == nil {
logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName())
alreadyExists = true
orig = secret.DeepCopy()
} else if !apierrors.IsNotFound(err) {
return "", err
}
if !alreadyExists {
if orig == nil {
// Secret doesn't exist yet, create one. Initially it contains
// only the Tailscale authkey, but once Tailscale starts it'll
// also store the daemon state.
@@ -218,8 +231,8 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
}
mak.Set(&secret.StringData, "serve-config", string(j))
}
if alreadyExists {
if err := a.Update(ctx, secret); err != nil {
if orig != nil {
if err := a.Patch(ctx, secret, client.MergeFrom(orig)); err != nil {
return "", err
}
} else {
@@ -305,11 +318,17 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Name: "TS_HOSTNAME",
Value: sts.Hostname,
})
if sts.TargetIP != "" {
if sts.ClusterTargetIP != "" {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_DEST_IP",
Value: sts.TargetIP,
Value: sts.ClusterTargetIP,
})
} else if sts.TailnetTargetIP != "" {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_TAILNET_TARGET_IP",
Value: sts.TailnetTargetIP,
})
} else if sts.ServeConfig != nil {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_SERVE_CONFIG",
@@ -350,10 +369,13 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
// container when the value changes. We do this by adding an annotation to
// the pod template that contains the last value we set.
ss.Spec.Template.Annotations = map[string]string{
"tailscale.com/operator-last-set-hostname": sts.Hostname,
podAnnotationLastSetHostname: sts.Hostname,
}
if sts.TargetIP != "" {
ss.Spec.Template.Annotations["tailscale.com/operator-last-set-ip"] = sts.TargetIP
if sts.ClusterTargetIP != "" {
ss.Spec.Template.Annotations[podAnnotationLastSetClusterIP] = sts.ClusterTargetIP
}
if sts.TailnetTargetIP != "" {
ss.Spec.Template.Annotations[podAnnotationLastSetTailnetTargetIP] = sts.TailnetTargetIP
}
ss.Spec.Template.Labels = map[string]string{
"app": sts.ParentResourceUID,

View File

@@ -10,13 +10,17 @@ import (
"fmt"
"net/netip"
"strings"
"sync"
"go.uber.org/zap"
"golang.org/x/exp/slices"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
type ServiceReconciler struct {
@@ -24,8 +28,26 @@ type ServiceReconciler struct {
ssr *tailscaleSTSReconciler
logger *zap.SugaredLogger
isDefaultLoadBalancer bool
mu sync.Mutex // protects following
// managedIngressProxies is a set of all ingress proxies that we're
// currently managing. This is only used for metrics.
managedIngressProxies set.Slice[types.UID]
// managedEgressProxies is a set of all egress proxies that we're currently
// managing. This is only used for metrics.
managedEgressProxies set.Slice[types.UID]
}
var (
// gaugeEgressProxies tracks the number of egress proxies that we're
// currently managing.
gaugeEgressProxies = clientmetric.NewGauge("k8s_egress_proxies")
// gaugeIngressProxies tracks the number of ingress proxies that we're
// currently managing.
gaugeIngressProxies = clientmetric.NewGauge("k8s_ingress_proxies")
)
func childResourceLabels(name, ns, typ string) map[string]string {
// You might wonder why we're using owner references, since they seem to be
// built for exactly this. Unfortunately, Kubernetes does not support
@@ -55,8 +77,8 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
} else if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
}
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) {
logger.Debugf("service is being deleted or should not be exposed, cleaning up")
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && !a.hasTailnetTargetAnnotation(svc) {
logger.Debugf("service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc)
}
@@ -71,6 +93,12 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
ix := slices.Index(svc.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
a.mu.Lock()
defer a.mu.Unlock()
a.managedIngressProxies.Remove(svc.UID)
a.managedEgressProxies.Remove(svc.UID)
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
return nil
}
@@ -91,6 +119,13 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
// cleanup removes the tailscale finalizer, which will make all future
// reconciles exit early.
logger.Infof("unexposed service from tailnet")
a.mu.Lock()
defer a.mu.Unlock()
a.managedIngressProxies.Remove(svc.UID)
a.managedEgressProxies.Remove(svc.UID)
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
return nil
}
@@ -122,24 +157,44 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
tags = strings.Split(tstr, ",")
}
clusterIPAddr, err := netip.ParseAddr(svc.Spec.ClusterIP)
if err != nil {
return fmt.Errorf("failed to parse cluster IP: %w", err)
}
sts := &tailscaleSTSConfig{
ParentResourceName: svc.Name,
ParentResourceUID: string(svc.UID),
TargetIP: svc.Spec.ClusterIP,
Hostname: hostname,
Tags: tags,
ChildResourceLabels: crl,
}
if err := a.ssr.Provision(ctx, logger, sts); err != nil {
a.mu.Lock()
if a.shouldExpose(svc) {
sts.ClusterTargetIP = svc.Spec.ClusterIP
a.managedIngressProxies.Add(svc.UID)
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
} else if a.hasTailnetTargetAnnotation(svc) {
sts.TailnetTargetIP = svc.Annotations[AnnotationTailnetTargetIP]
a.managedEgressProxies.Add(svc.UID)
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
}
a.mu.Unlock()
var hsvc *corev1.Service
if hsvc, err = a.ssr.Provision(ctx, logger, sts); err != nil {
return fmt.Errorf("failed to provision: %w", err)
}
if a.hasTailnetTargetAnnotation(svc) {
headlessSvcName := hsvc.Name + "." + hsvc.Namespace + ".svc"
if svc.Spec.ExternalName != headlessSvcName || svc.Spec.Type != corev1.ServiceTypeExternalName {
svc.Spec.ExternalName = headlessSvcName
svc.Spec.Selector = nil
svc.Spec.Type = corev1.ServiceTypeExternalName
if err := a.Update(ctx, svc); err != nil {
return fmt.Errorf("failed to update service: %w", err)
}
}
return nil
}
if !a.hasLoadBalancerClass(svc) {
logger.Debugf("service is not a LoadBalancer, so not updating ingress")
return nil
@@ -163,6 +218,10 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
ingress := []corev1.LoadBalancerIngress{
{Hostname: tsHost},
}
clusterIPAddr, err := netip.ParseAddr(svc.Spec.ClusterIP)
if err != nil {
return fmt.Errorf("failed to parse cluster IP: %w", err)
}
for _, ip := range tsIPs {
addr, err := netip.ParseAddr(ip)
if err != nil {
@@ -186,7 +245,7 @@ func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool {
return false
}
return a.hasLoadBalancerClass(svc) || a.hasAnnotation(svc)
return a.hasLoadBalancerClass(svc) || a.hasExposeAnnotation(svc)
}
func (a *ServiceReconciler) hasLoadBalancerClass(svc *corev1.Service) bool {
@@ -196,7 +255,14 @@ func (a *ServiceReconciler) hasLoadBalancerClass(svc *corev1.Service) bool {
svc.Spec.LoadBalancerClass == nil && a.isDefaultLoadBalancer)
}
func (a *ServiceReconciler) hasAnnotation(svc *corev1.Service) bool {
return svc != nil &&
svc.Annotations[AnnotationExpose] == "true"
// hasExposeAnnotation reports whether Service has the tailscale.com/expose
// annotation set
func (a *ServiceReconciler) hasExposeAnnotation(svc *corev1.Service) bool {
return svc != nil && svc.Annotations[AnnotationExpose] == "true"
}
// hasTailnetTargetAnnotation reports whether Service has a
// tailscale.com/ts-tailnet-target-ip annotation set
func (a *ServiceReconciler) hasTailnetTargetAnnotation(svc *corev1.Service) bool {
return svc != nil && svc.Annotations[AnnotationTailnetTargetIP] != ""
}

View File

@@ -16,10 +16,12 @@ import (
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/peterbourgon/ff/v3"
"golang.org/x/net/dns/dnsmessage"
"inet.af/tcpproxy"
"tailscale.com/client/tailscale"
@@ -32,14 +34,6 @@ import (
"tailscale.com/util/clientmetric"
)
var (
ports = flag.String("ports", "443", "comma-separated list of ports to proxy")
forwards = flag.String("forwards", "", "comma-separated list of ports to transparently forward, protocol/number/destination. For example, --forwards=tcp/22/github.com,tcp/5432/sql.example.com")
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")
debugPort = flag.Int("debug-port", 8080, "Listening port for debug/metrics endpoint")
)
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
// portForward is the state for a single port forwarding entry, as passed to the --forward flag.
@@ -74,7 +68,19 @@ func parseForward(value string) (*portForward, error) {
}
func main() {
flag.Parse()
fs := flag.NewFlagSet("sniproxy", flag.ContinueOnError)
var (
ports = fs.String("ports", "443", "comma-separated list of ports to proxy")
forwards = fs.String("forwards", "", "comma-separated list of ports to transparently forward, protocol/number/destination. For example, --forwards=tcp/22/github.com,tcp/5432/sql.example.com")
wgPort = fs.Int("wg-listen-port", 0, "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
promoteHTTPS = fs.Bool("promote-https", true, "promote HTTP to HTTPS")
debugPort = fs.Int("debug-port", 8893, "Listening port for debug/metrics endpoint")
)
err := ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("TS_APPC"))
if err != nil {
log.Fatal("ff.Parse")
}
if *ports == "" {
log.Fatal("no ports")
}
@@ -126,7 +132,6 @@ func main() {
})
go s.forward(ln, forw)
}
ln, err := s.ts.Listen("udp", ":53")

View File

@@ -121,7 +121,7 @@ change in the future.
ncCmd,
sshCmd,
funnelCmd(),
serveCmd,
serveCmd(),
versionCmd,
webCmd,
fileCmd,
@@ -130,6 +130,7 @@ change in the future.
netlockCmd,
licensesCmd,
exitNodeCmd,
updateCmd,
},
FlagSet: rootfs,
Exec: func(context.Context, []string) error { return flag.ErrHelp },
@@ -145,8 +146,6 @@ change in the future.
switch {
case slices.Contains(args, "debug"):
rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd)
case slices.Contains(args, "update"):
rootCmd.Subcommands = append(rootCmd.Subcommands, updateCmd)
}
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)

View File

@@ -556,6 +556,10 @@ func TestPrefsFromUpArgs(t *testing.T) {
NetfilterMode: preftype.NetfilterOn,
CorpDNS: true,
AllowSingleHosts: true,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
Apply: false,
},
},
},
{
@@ -569,6 +573,10 @@ func TestPrefsFromUpArgs(t *testing.T) {
AllowSingleHosts: true,
RouteAll: true,
NetfilterMode: preftype.NetfilterOn,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
Apply: false,
},
},
},
{
@@ -584,6 +592,10 @@ func TestPrefsFromUpArgs(t *testing.T) {
netip.MustParsePrefix("::/0"),
},
NetfilterMode: preftype.NetfilterOn,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
Apply: false,
},
},
},
{
@@ -670,6 +682,10 @@ func TestPrefsFromUpArgs(t *testing.T) {
WantRunning: true,
NetfilterMode: preftype.NetfilterNoDivert,
NoSNAT: true,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
Apply: false,
},
},
},
{
@@ -683,6 +699,10 @@ func TestPrefsFromUpArgs(t *testing.T) {
WantRunning: true,
NetfilterMode: preftype.NetfilterOff,
NoSNAT: true,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
Apply: false,
},
},
},
{
@@ -698,6 +718,10 @@ func TestPrefsFromUpArgs(t *testing.T) {
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"),
},
AutoUpdate: ipn.AutoUpdatePrefs{
Check: true,
Apply: false,
},
},
},
{

View File

@@ -138,6 +138,11 @@ var debugCmd = &ffcli.Command{
Exec: localAPIAction("break-derp-conns"),
ShortHelp: "break any open DERP connections from the daemon",
},
{
Name: "control-knobs",
Exec: debugControlKnobs,
ShortHelp: "see current control knobs",
},
{
Name: "prefs",
Exec: runPrefs,
@@ -915,3 +920,17 @@ func runPeerEndpointChanges(ctx context.Context, args []string) error {
fmt.Printf("%s", dst.String())
return nil
}
func debugControlKnobs(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("unexpected arguments")
}
v, err := localClient.DebugResultJSON(ctx, "control-knobs")
if err != nil {
return err
}
e := json.NewEncoder(os.Stdout)
e.SetIndent("", " ")
e.Encode(v)
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
@@ -25,8 +26,8 @@ var funnelCmd = func() *ffcli.Command {
// This flag is used to switch to an in-development
// implementation of the tailscale funnel command.
// See https://github.com/tailscale/tailscale/issues/7844
if os.Getenv("TAILSCALE_FUNNEL_DEV") == "on" {
return newFunnelDevCommand(se)
if envknob.UseWIPCode() {
return newServeDevCommand(se, funnel)
}
return newFunnelCommand(se)
}

View File

@@ -1,112 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
)
// newFunnelDevCommand returns a new "funnel" subcommand using e as its environment.
// The funnel subcommand is used to turn on/off the Funnel service.
// Funnel is off by default.
// Funnel allows you to publish a 'tailscale serve' server publicly,
// open to the entire internet.
// newFunnelCommand shares the same serveEnv as the "serve" subcommand.
// See newServeCommand and serve.go for more details.
func newFunnelDevCommand(e *serveEnv) *ffcli.Command {
return &ffcli.Command{
Name: "funnel",
ShortHelp: "Turn on/off Funnel service",
ShortUsage: strings.Join([]string{
"funnel <port>",
"funnel status [--json]",
}, "\n "),
LongHelp: strings.Join([]string{
"Funnel allows you to expose your local",
"server publicly to the entire internet.",
"Note that it only supports https servers at this point.",
"This command is in development and is unsupported",
}, "\n"),
Exec: e.runFunnelDev,
UsageFunc: usageFunc,
Subcommands: []*ffcli.Command{
{
Name: "status",
Exec: e.runServeStatus,
ShortHelp: "show current serve/Funnel status",
FlagSet: e.newFlags("funnel-status", func(fs *flag.FlagSet) {
fs.BoolVar(&e.json, "json", false, "output JSON")
}),
UsageFunc: usageFunc,
},
},
}
}
// runFunnelDev is the entry point for the "tailscale funnel" subcommand and
// manages turning on/off Funnel. Funnel is off by default.
//
// Note: funnel is only supported on single DNS name for now. (2023-08-18)
func (e *serveEnv) runFunnelDev(ctx context.Context, args []string) error {
if len(args) != 1 {
return flag.ErrHelp
}
var source string
port64, err := strconv.ParseUint(args[0], 10, 16)
if err == nil {
source = fmt.Sprintf("http://127.0.0.1:%d", port64)
} else {
source, err = expandProxyTarget(args[0])
}
if err != nil {
return err
}
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil {
return err
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
hp := ipn.HostPort(dnsName + ":443") // TODO(marwan-at-work): support the 2 other ports
// In the streaming case, the process stays running in the
// foreground and prints out connections to the HostPort.
//
// The local backend handles updating the ServeConfig as
// necessary, then restores it to its original state once
// the process's context is closed or the client turns off
// Tailscale.
return e.streamServe(ctx, ipn.ServeStreamRequest{
HostPort: hp,
Source: source,
MountPoint: "/", // TODO(marwan-at-work): support multiple mount points
})
}
func (e *serveEnv) streamServe(ctx context.Context, req ipn.ServeStreamRequest) error {
stream, err := e.lc.StreamServe(ctx, req)
if err != nil {
return err
}
defer stream.Close()
fmt.Fprintf(os.Stderr, "Funnel started on \"https://%s\".\n", strings.TrimSuffix(string(req.HostPort), ":443"))
fmt.Fprintf(os.Stderr, "Press Ctrl-C to stop Funnel.\n\n")
_, err = io.Copy(os.Stdout, stream)
return err
}

View File

@@ -53,7 +53,7 @@ func runNetcheck(ctx context.Context, args []string) error {
return err
}
c := &netcheck.Client{
PortMapper: portmapper.NewClient(logf, netMon, nil, nil),
PortMapper: portmapper.NewClient(logf, netMon, nil, nil, nil),
UseDNSCache: false, // always resolve, don't cache
}
if netcheckArgs.verbose {
@@ -153,7 +153,11 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
if len(report.RegionLatency) == 0 {
printf("\t* Nearest DERP: unknown (no response to latency probes)\n")
} else {
printf("\t* Nearest DERP: %v\n", dm.Regions[report.PreferredDERP].RegionName)
if report.PreferredDERP != 0 {
printf("\t* Nearest DERP: %v\n", dm.Regions[report.PreferredDERP].RegionName)
} else {
printf("\t* Nearest DERP: [none]\n")
}
printf("\t* DERP latency:\n")
var rids []int
for rid := range dm.Regions {

View File

@@ -25,6 +25,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
@@ -32,7 +33,16 @@ import (
"tailscale.com/version"
)
var serveCmd = newServeCommand(&serveEnv{lc: &localClient})
var serveCmd = func() *ffcli.Command {
se := &serveEnv{lc: &localClient}
// This flag is used to switch to an in-development
// implementation of the tailscale funnel command.
// See https://github.com/tailscale/tailscale/issues/7844
if envknob.UseWIPCode() {
return newServeDevCommand(se, serve)
}
return newServeCommand(se)
}
// newServeCommand returns a new "serve" subcommand using e as its environment.
func newServeCommand(e *serveEnv) *ffcli.Command {
@@ -110,6 +120,10 @@ EXAMPLES
}
}
// errHelp is standard error text that prompts users to
// run `serve --help` for information on how to use serve.
var errHelp = errors.New("try `tailscale serve --help` for usage info")
func (e *serveEnv) newFlags(name string, setup func(fs *flag.FlagSet)) *flag.FlagSet {
onError, out := flag.ExitOnError, Stderr
if e.testFlagOut != nil {
@@ -135,7 +149,6 @@ type localServeClient interface {
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
StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) // TODO: testing :)
}
// serveEnv is the environment the serve command runs within. All I/O should be
@@ -145,9 +158,18 @@ type localServeClient interface {
//
// It also contains the flags, as registered with newServeCommand.
type serveEnv struct {
// flags
// v1 flags
json bool // output JSON (status only for now)
// v2 specific flags
bg bool // background mode
setPath string // serve path
https string // HTTP port
http string // HTTP port
tcp string // TCP port
tlsTerminatedTCP string // a TLS terminated TCP port
subcmd serveMode // subcommand
lc localServeClient // localClient interface, specific to serve
// optional stuff for tests:
@@ -234,7 +256,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
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
return errHelp
}
if srcType == "https" && !turnOff {
@@ -276,7 +298,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
default:
fmt.Fprintf(os.Stderr, "error: invalid serve type %q\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
return errHelp
}
}
@@ -312,13 +334,13 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bo
}
if !filepath.IsAbs(source) {
fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n")
return flag.ErrHelp
return errHelp
}
source = filepath.Clean(source)
fi, err := os.Stat(source)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err)
return flag.ErrHelp
return errHelp
}
if fi.IsDir() && !strings.HasSuffix(mount, "/") {
// dir mount points must end in /
@@ -344,7 +366,7 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bo
if sc.IsTCPForwardingOnPort(srvPort) {
fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
return flag.ErrHelp
return errHelp
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
@@ -532,18 +554,18 @@ func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort u
terminateTLS = true
default:
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n\n", dest)
return flag.ErrHelp
return errHelp
}
dstURL, err := url.Parse(dest)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err)
return flag.ErrHelp
return errHelp
}
host, dstPortStr, err := net.SplitHostPort(dstURL.Host)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err)
return flag.ErrHelp
return errHelp
}
switch host {
@@ -552,12 +574,12 @@ func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort u
default:
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n", dest)
fmt.Fprint(os.Stderr, "must be one of: localhost or 127.0.0.1\n\n", dest)
return flag.ErrHelp
return errHelp
}
if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil {
fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", dstPortStr)
return flag.ErrHelp
return errHelp
}
cursc, err := e.lc.GetServeConfig(ctx)

View File

@@ -0,0 +1,755 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"net/url"
"os"
"os/signal"
"path"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
"tailscale.com/version"
)
type execFunc func(ctx context.Context, args []string) error
type commandInfo struct {
Name string
ShortHelp string
LongHelp string
}
var serveHelpCommon = strings.TrimSpace(`
<target> can be a port number (e.g., 3000), a partial URL (e.g., localhost:3000), or a
full URL including a path (e.g., http://localhost:3000/foo, https+insecure://localhost:3000/foo).
EXAMPLES
- Mount a local web server at 127.0.0.1:3000 in the foreground:
$ tailscale %s localhost:3000
- Mount a local web server at 127.0.0.1:3000 in the background:
$ tailscale %s --bg localhost:3000
For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases
`)
type serveMode int
const (
serve serveMode = iota
funnel
)
type serveType int
const (
serveTypeHTTPS serveType = iota
serveTypeHTTP
serveTypeTCP
serveTypeTLSTerminatedTCP
)
var infoMap = map[serveMode]commandInfo{
serve: {
Name: "serve",
ShortHelp: "Serve content and local servers on your tailnet",
LongHelp: strings.Join([]string{
"Serve enables you to share a local server securely within your tailnet.\n",
"To share a local server on the internet, use `tailscale funnel`\n\n",
}, "\n"),
},
funnel: {
Name: "funnel",
ShortHelp: "Serve content and local servers on the internet",
LongHelp: strings.Join([]string{
"Funnel enables you to share a local server on the internet using Tailscale.\n",
"To share only within your tailnet, use `tailscale serve`\n\n",
}, "\n"),
},
}
func buildShortUsage(subcmd string) string {
return strings.Join([]string{
subcmd + " [flags] <target> [off]",
subcmd + " status [--json]",
subcmd + " reset",
}, "\n ")
}
// newServeDevCommand returns a new "serve" subcommand using e as its environment.
func newServeDevCommand(e *serveEnv, subcmd serveMode) *ffcli.Command {
if subcmd != serve && subcmd != funnel {
log.Fatalf("newServeDevCommand called with unknown subcmd %q", subcmd)
}
info := infoMap[subcmd]
return &ffcli.Command{
Name: info.Name,
ShortHelp: info.ShortHelp,
ShortUsage: strings.Join([]string{
fmt.Sprintf("%s <target>", info.Name),
fmt.Sprintf("%s status [--json]", info.Name),
fmt.Sprintf("%s reset", info.Name),
}, "\n "),
LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), info.Name, info.Name),
Exec: e.runServeCombined(subcmd),
FlagSet: e.newFlags("serve-set", func(fs *flag.FlagSet) {
fs.BoolVar(&e.bg, "bg", false, "run the command in the background")
fs.StringVar(&e.setPath, "set-path", "", "set a path for a specific target and run in the background")
fs.StringVar(&e.https, "https", "", "default; HTTPS listener")
fs.StringVar(&e.http, "http", "", "HTTP listener")
fs.StringVar(&e.tcp, "tcp", "", "TCP listener")
fs.StringVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", "", "TLS terminated TCP listener")
}),
UsageFunc: usageFunc,
Subcommands: []*ffcli.Command{
{
Name: "status",
Exec: e.runServeStatus,
ShortHelp: "view current proxy configuration",
FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) {
fs.BoolVar(&e.json, "json", false, "output JSON")
}),
UsageFunc: usageFunc,
},
{
Name: "reset",
ShortHelp: "reset current serve/funnel config",
Exec: e.runServeReset,
FlagSet: e.newFlags("serve-reset", nil),
UsageFunc: usageFunc,
},
},
}
}
func validateArgs(subcmd serveMode, args []string) error {
switch len(args) {
case 0:
return flag.ErrHelp
case 1, 2:
if isLegacyInvocation(subcmd, args) {
fmt.Fprintf(os.Stderr, "error: the CLI for serve and funnel has changed.")
fmt.Fprintf(os.Stderr, "Please see https://tailscale.com/kb/1242/tailscale-serve for more information.")
return errHelp
}
default:
fmt.Fprintf(os.Stderr, "error: invalid number of arguments (%d)", len(args))
return errHelp
}
return nil
}
// runServeCombined is the entry point for the "tailscale {serve,funnel}" commands.
func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
e.subcmd = subcmd
return func(ctx context.Context, args []string) error {
if err := validateArgs(subcmd, args); err != nil {
return err
}
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
funnel := subcmd == funnel
if funnel {
// verify node has funnel capabilities
if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil {
return err
}
}
mount, err := cleanURLPath(e.setPath)
if err != nil {
return fmt.Errorf("failed to clean the mount point: %w", err)
}
if e.setPath != "" {
// TODO(marwan-at-work): either
// 1. Warn the user that this is a side effect.
// 2. Force the user to pass --bg
// 3. Allow set-path to be in the foreground.
e.bg = true
}
srvType, srvPort, err := srvTypeAndPortFromFlags(e)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
return errHelp
}
sc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return fmt.Errorf("error getting serve config: %w", err)
}
// nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
// set parent serve config to always be persisted
// at the top level, but a nested config might be
// the one that gets manipulated depending on
// foreground or background.
parentSC := sc
turnOff := "off" == args[len(args)-1]
if !turnOff && srvType == serveTypeHTTPS {
// 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.
if err := e.enableFeatureInteractive(ctx, "serve", func(caps []string) bool {
return slices.Contains(caps, tailcfg.CapabilityHTTPS)
}); err != nil {
return fmt.Errorf("error enabling https feature: %w", err)
}
}
var watcher *tailscale.IPNBusWatcher
if !e.bg && !turnOff {
// if foreground mode, create a WatchIPNBus session
// and use the nested config for all following operations
// TODO(marwan-at-work): nested-config validations should happen here or previous to this point.
watcher, err = e.lc.WatchIPNBus(ctx, ipn.NotifyInitialState)
if err != nil {
return err
}
defer watcher.Close()
n, err := watcher.Next()
if err != nil {
return err
}
if n.SessionID == "" {
return errors.New("missing SessionID")
}
fsc := &ipn.ServeConfig{}
mak.Set(&sc.Foreground, n.SessionID, fsc)
sc = fsc
}
var msg string
if turnOff {
err = e.unsetServe(sc, dnsName, srvType, srvPort, mount)
} else {
err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel)
msg = e.messageForPort(sc, st, dnsName, srvPort)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
return errHelp
}
if err := e.lc.SetServeConfig(ctx, parentSC); err != nil {
return err
}
if msg != "" {
fmt.Fprintln(os.Stderr, msg)
}
if watcher != nil {
for {
_, err = watcher.Next()
if err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
}
}
return nil
}
}
func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool) error {
// update serve config based on the type
switch srvType {
case serveTypeHTTPS, serveTypeHTTP:
useTLS := srvType == serveTypeHTTPS
err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target)
if err != nil {
return fmt.Errorf("failed apply web serve: %w", err)
}
case serveTypeTCP, serveTypeTLSTerminatedTCP:
err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target)
if err != nil {
return fmt.Errorf("failed to apply TCP serve: %w", err)
}
default:
return fmt.Errorf("invalid type %q", srvType)
}
// update the serve config based on if funnel is enabled
e.applyFunnel(sc, dnsName, srvPort, allowFunnel)
return nil
}
// messageForPort returns a message for the given port based on the
// serve config and status.
func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvPort uint16) string {
var output strings.Builder
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if sc.AllowFunnel[hp] == true {
output.WriteString("Available on the internet:\n")
} else {
output.WriteString("Available within your tailnet:\n")
}
scheme := "https"
if sc.IsServingHTTP(srvPort) {
scheme = "http"
}
portPart := ":" + fmt.Sprint(srvPort)
if scheme == "http" && srvPort == 80 ||
scheme == "https" && srvPort == 443 {
portPart = ""
}
output.WriteString(fmt.Sprintf("%s://%s%s\n\n", scheme, dnsName, portPart))
if !e.bg {
output.WriteString("Press Ctrl+C to exit.")
return output.String()
}
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
switch {
case h.Path != "":
return "path", h.Path
case h.Proxy != "":
return "proxy", h.Proxy
case h.Text != "":
return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\""
}
return "", ""
}
if sc.Web[hp] != nil {
var mounts []string
for k := range sc.Web[hp].Handlers {
mounts = append(mounts, k)
}
sort.Slice(mounts, func(i, j int) bool {
return len(mounts[i]) < len(mounts[j])
})
maxLen := len(mounts[len(mounts)-1])
for _, m := range mounts {
h := sc.Web[hp].Handlers[m]
t, d := srvTypeAndDesc(h)
output.WriteString(fmt.Sprintf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d))
}
} else if sc.TCP[srvPort] != nil {
h := sc.TCP[srvPort]
tlsStatus := "TLS over TCP"
if h.TerminateTLS != "" {
tlsStatus = "TLS terminated"
}
output.WriteString(fmt.Sprintf("|-- tcp://%s (%s)\n", hp, tlsStatus))
for _, a := range st.TailscaleIPs {
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort)))
output.WriteString(fmt.Sprintf("|-- tcp://%s\n", ipp))
}
output.WriteString(fmt.Sprintf("|--> tcp://%s\n", h.TCPForward))
}
output.WriteString("\nServe started and running in the background.\n")
output.WriteString(fmt.Sprintf("To disable the proxy, run: tailscale %s off", infoMap[e.subcmd].Name))
return output.String()
}
func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target string) error {
h := new(ipn.HTTPHandler)
switch {
case strings.HasPrefix(target, "text:"):
text := strings.TrimPrefix(target, "text:")
if text == "" {
return errors.New("unable to serve; text cannot be an empty string")
}
h.Text = text
case filepath.IsAbs(target):
if version.IsSandboxedMacOS() {
// don't allow path serving for now on macOS (2022-11-15)
return errors.New("path serving is not supported if sandboxed on macOS")
}
target = filepath.Clean(target)
fi, err := os.Stat(target)
if err != nil {
return errors.New("invalid path")
}
// TODO: need to understand this further
if fi.IsDir() && !strings.HasSuffix(mount, "/") {
// dir mount points must end in /
// for relative file links to work
mount += "/"
}
h.Path = target
default:
t, err := expandProxyTargetDev(target)
if err != nil {
return err
}
h.Proxy = t
}
// TODO: validation needs to check nested foreground configs
if sc.IsTCPForwardingOnPort(srvPort) {
return errors.New("cannot serve web; already serving TCP")
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
}
mak.Set(&sc.Web[hp].Handlers, mount, h)
// TODO: handle multiple web handlers from foreground mode
for k, v := range sc.Web[hp].Handlers {
if v == h {
continue
}
// If the new mount point ends in / and another mount point
// shares the same prefix, remove the other handler.
// (e.g. /foo/ overwrites /foo)
// The opposite example is also handled.
m1 := strings.TrimSuffix(mount, "/")
m2 := strings.TrimSuffix(k, "/")
if m1 == m2 {
delete(sc.Web[hp].Handlers, k)
}
}
return nil
}
func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType serveType, srcPort uint16, target string) error {
var terminateTLS bool
switch srcType {
case serveTypeTCP:
terminateTLS = false
case serveTypeTLSTerminatedTCP:
terminateTLS = true
default:
return fmt.Errorf("invalid TCP target %q", target)
}
dstURL, err := url.Parse(target)
if err != nil {
return fmt.Errorf("invalid TCP target %q: %v", target, err)
}
host, dstPortStr, err := net.SplitHostPort(dstURL.Host)
if err != nil {
return fmt.Errorf("invalid TCP target %q: %v", target, err)
}
switch host {
case "localhost", "127.0.0.1":
// ok
default:
return fmt.Errorf("invalid TCP target %q, must be one of localhost or 127.0.0.1", target)
}
if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil {
return fmt.Errorf("invalid port %q", dstPortStr)
}
fwdAddr := "127.0.0.1:" + dstPortStr
// TODO: needs to account for multiple configs from foreground mode
if sc.IsServingWeb(srcPort) {
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
}
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
if terminateTLS {
sc.TCP[srcPort].TerminateTLS = dnsName
}
return nil
}
func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint16, allowFunnel bool) {
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
// TODO: Should we return an error? Should not be possible.
// nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
// TODO: should ensure there is no other conflicting funnel
// TODO: add error handling for if toggling for existing sc
if allowFunnel {
mak.Set(&sc.AllowFunnel, hp, true)
}
}
// unsetServe removes the serve config for the given serve port.
func (e *serveEnv) unsetServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string) error {
switch srvType {
case serveTypeHTTPS, serveTypeHTTP:
err := e.removeWebServe(sc, dnsName, srvPort, mount)
if err != nil {
return fmt.Errorf("failed to remove web serve: %w", err)
}
case serveTypeTCP, serveTypeTLSTerminatedTCP:
err := e.removeTCPServe(sc, srvPort)
if err != nil {
return fmt.Errorf("failed to remove TCP serve: %w", err)
}
default:
return fmt.Errorf("invalid type %q", srvType)
}
// TODO(tylersmalley): remove funnel
return nil
}
func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, err error) {
sourceMap := map[serveType]string{
serveTypeHTTP: e.http,
serveTypeHTTPS: e.https,
serveTypeTCP: e.tcp,
serveTypeTLSTerminatedTCP: e.tlsTerminatedTCP,
}
var srcTypeCount int
var srcValue string
for k, v := range sourceMap {
if v != "" {
srcTypeCount++
srvType = k
srcValue = v
}
}
if srcTypeCount > 1 {
return 0, 0, fmt.Errorf("cannot serve multiple types for a single mount point")
} else if srcTypeCount == 0 {
srvType = serveTypeHTTPS
srcValue = "443"
}
srvPort, err = parseServePort(srcValue)
if err != nil {
return 0, 0, fmt.Errorf("invalid port %q: %w", srcValue, err)
}
return srvType, srvPort, nil
}
func isLegacyInvocation(subcmd serveMode, args []string) bool {
if subcmd == serve && len(args) == 2 {
prefixes := []string{"http", "https", "tcp", "tls-terminated-tcp"}
for _, prefix := range prefixes {
if strings.HasPrefix(args[0], prefix) {
return true
}
}
}
return false
}
// removeWebServe removes a web handler from the serve config
// and removes funnel if no remaining mounts exist for the serve port.
// The srvPort argument is the serving port and the mount argument is
// the mount point or registered path to remove.
func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, mount string) error {
if sc.IsTCPForwardingOnPort(srvPort) {
return errors.New("cannot remove web handler; currently serving TCP")
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if !sc.WebHandlerExists(hp, mount) {
return errors.New("error: handler does not exist")
}
// delete existing handler, then cascade delete if empty
delete(sc.Web[hp].Handlers, mount)
if len(sc.Web[hp].Handlers) == 0 {
delete(sc.Web, hp)
delete(sc.TCP, srvPort)
}
// clear empty maps mostly for testing
if len(sc.Web) == 0 {
sc.Web = nil
}
if len(sc.TCP) == 0 {
sc.TCP = nil
}
// disable funnel if no remaining mounts exist for the serve port
if sc.Web == nil && sc.TCP == nil {
delete(sc.AllowFunnel, hp)
}
return nil
}
// removeTCPServe removes the TCP forwarding configuration for the
// given srvPort, or serving port.
func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error {
if sc == nil {
return nil
}
if sc.GetTCPPortHandler(src) == nil {
return errors.New("error: serve config does not exist")
}
if sc.IsServingWeb(src) {
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
}
delete(sc.TCP, src)
// clear map mostly for testing
if len(sc.TCP) == 0 {
sc.TCP = nil
}
return nil
}
// expandProxyTargetDev expands the supported target values to be proxied
// allowing for input values to be a port number, a partial URL, or a full URL
// including a path.
//
// examples:
// - 3000
// - localhost:3000
// - http://localhost:3000
// - https://localhost:3000
// - https-insecure://localhost:3000
// - https-insecure://localhost:3000/foo
func expandProxyTargetDev(target string) (string, error) {
var (
scheme = "http"
host = "127.0.0.1"
)
// support target being a port number
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
return fmt.Sprintf("%s://%s:%d", scheme, host, port), nil
}
// prepend scheme if not present
if !strings.Contains(target, "://") {
target = scheme + "://" + target
}
// make sure we can parse the target
u, err := url.ParseRequestURI(target)
if err != nil {
return "", fmt.Errorf("invalid URL %w", err)
}
// ensure a supported scheme
switch u.Scheme {
case "http", "https", "https+insecure":
default:
return "", errors.New("must be a URL starting with http://, https://, or https+insecure://")
}
// validate the port
port, err := strconv.ParseUint(u.Port(), 10, 16)
if err != nil || port == 0 {
return "", fmt.Errorf("invalid port %q", u.Port())
}
// validate the host.
switch u.Hostname() {
case "localhost", "127.0.0.1":
u.Host = fmt.Sprintf("%s:%d", host, port)
default:
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
}
return u.String(), nil
}
// cleanURLPath ensures the path is clean and has a leading "/".
func cleanURLPath(urlPath string) (string, error) {
if urlPath == "" {
return "/", nil
}
// TODO(tylersmalley) verify still needed with path being a flag
urlPath = cleanMinGWPathConversionIfNeeded(urlPath)
if !strings.HasPrefix(urlPath, "/") {
urlPath = "/" + urlPath
}
c := path.Clean(urlPath)
if urlPath == c || urlPath == c+"/" {
return urlPath, nil
}
return "", fmt.Errorf("invalid mount point %q", urlPath)
}
func (s serveType) String() string {
switch s {
case serveTypeHTTP:
return "httpListener"
case serveTypeHTTPS:
return "httpsListener"
case serveTypeTCP:
return "tcpListener"
case serveTypeTLSTerminatedTCP:
return "tlsTerminatedTCPListener"
default:
return "unknownServeType"
}
}

View File

@@ -0,0 +1,955 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/types/logger"
)
func TestServeDevConfigMutations(t *testing.T) {
// Stateful mutations, starting from an empty config.
type step struct {
command []string // serve args; nil means no command to run (only reset)
reset bool // if true, reset all ServeConfig state
want *ipn.ServeConfig // non-nil means we want a save of this value
wantErr func(error) (badErrMsg string) // nil means no error is wanted
line int // line number of addStep call, for error messages
debugBreak func()
}
var steps []step
add := func(s step) {
_, _, s.line, _ = runtime.Caller(1)
steps = append(steps, s)
}
// using port number
add(step{reset: true})
add(step{
command: cmd("funnel --bg 3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
},
})
// funnel background
add(step{reset: true})
add(step{
command: cmd("funnel --bg localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
},
})
// serve background
add(step{reset: true})
add(step{
command: cmd("serve --bg localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
// --set-path runs in background
add(step{reset: true})
add(step{
command: cmd("serve --set-path=/ localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
// using http listener
add(step{reset: true})
add(step{
command: cmd("serve --bg --http=80 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"},
}},
},
},
})
// using https listener with a valid port
add(step{reset: true})
add(step{
command: cmd("serve --bg --https=8443 localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
// https
add(step{reset: true})
add(step{ // allow omitting port (default to 80)
command: cmd("serve --http=80 --bg 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("serve --http=9999 --set-path=/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("serve --http=9999 --set-path=/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("serve --http=8080 --set-path=/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{
command: cmd("serve --https=443 --bg http://localhost:0"), // invalid port, too low
wantErr: anyErr(),
})
add(step{
command: cmd("serve --https=443 --bg http://localhost:65536"), // invalid port, too high
wantErr: anyErr(),
})
add(step{
command: cmd("serve --https=443 --bg http://somehost:3000"), // invalid host
wantErr: anyErr(),
})
add(step{
command: cmd("serve --https=443 --bg httpz://127.0.0.1"), // invalid scheme
wantErr: anyErr(),
})
add(step{ // allow omitting port (default to 443)
command: cmd("serve --https=443 --bg http://localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // support non Funnel port
command: cmd("serve --https=9999 --set-path=/abc http://localhost:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 9999: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {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("serve --https=9999 --set-path=/abc off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{
command: cmd("serve --https=8443 --set-path=/abc http://127.0.0.1:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{
command: cmd("serve --https=10000 --bg text:hi"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
"foo.test.ts.net:10000": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "hi"},
}},
},
},
})
add(step{
command: cmd("serve --https=443 --set-path=/foo off"),
want: nil, // nothing to save
wantErr: anyErr(),
}) // handler doesn't exist, so we get an error
add(step{
command: cmd("serve --https=10000 off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{
command: cmd("serve --https=443 off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{
command: cmd("serve --https=8443 --set-path=/abc off"),
want: &ipn.ServeConfig{},
})
add(step{ // clean mount: "bar" becomes "/bar"
command: cmd("serve --https=443 --set-path=bar https://127.0.0.1:8443"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "https://127.0.0.1:8443"},
}},
},
},
})
// add(step{
// command: cmd("serve --https=443 --set-path=bar https://127.0.0.1:8443"),
// want: nil, // nothing to save
// })
add(step{ // try resetting using reset command
command: cmd("serve reset"),
want: &ipn.ServeConfig{},
})
add(step{
command: cmd("serve --https=443 --bg https+insecure://127.0.0.1:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "https+insecure://127.0.0.1:3001"},
}},
},
},
})
add(step{reset: true})
add(step{
command: cmd("serve --https=443 --set-path=/foo localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // test a second handler on the same port
command: cmd("serve --https=8443 --set-path=/foo localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{reset: true})
add(step{ // support path in proxy
command: cmd("serve --https=443 --bg http://127.0.0.1:3000/foo/bar"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000/foo/bar"},
}},
},
},
})
// // tcp
add(step{reset: true})
add(step{ // must include scheme for tcp
command: cmd("serve --tls-terminated-tcp=443 --bg localhost:5432"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{ // !somehost, must be localhost or 127.0.0.1
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:5432"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{ // bad target port, too low
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:0"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{ // bad target port, too high
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:65536"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:5432",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8443"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:8443",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
// add(step{
// command: cmd("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8443"),
// want: nil, // nothing to save
// })
add(step{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:8444"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:8444",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8445"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:8445",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{reset: true})
add(step{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:123"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:123",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{ // handler doesn't exist, so we get an error
command: cmd("serve --tls-terminated-tcp=8443 off"),
wantErr: anyErr(),
})
add(step{
command: cmd("serve --tls-terminated-tcp=443 off"),
want: &ipn.ServeConfig{},
})
// // text
add(step{reset: true})
add(step{
command: cmd("serve --https=443 --bg text:hello"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "hello"},
}},
},
},
})
// path
td := t.TempDir()
writeFile := func(suffix, contents string) {
if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil {
t.Fatal(err)
}
}
add(step{reset: true})
writeFile("foo", "this is foo")
add(step{
command: cmd("serve --https=443 --bg " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: filepath.Join(td, "foo")},
}},
},
},
})
os.MkdirAll(filepath.Join(td, "subdir"), 0700)
writeFile("subdir/file-a", "this is A")
add(step{
command: cmd("serve --https=443 --set-path=/some/where " + filepath.Join(td, "subdir/file-a")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: filepath.Join(td, "foo")},
"/some/where": {Path: filepath.Join(td, "subdir/file-a")},
}},
},
},
})
add(step{ // bad path
command: cmd("serve --https=443 --bg bad/path"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{reset: true})
add(step{
command: cmd("serve --https=443 --bg " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: filepath.Join(td, "subdir/")},
}},
},
},
})
add(step{
command: cmd("serve --https=443 off"),
want: &ipn.ServeConfig{},
})
// // combos
add(step{reset: true})
add(step{
command: cmd("serve --bg localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // enable funnel for primary port
command: cmd("funnel --bg localhost:3000"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // serving on secondary port doesn't change funnel on primary port
command: cmd("serve --https=8443 --set-path=/bar localhost:3001"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{ // turn funnel on for secondary port
command: cmd("funnel --https=8443 --set-path=/bar localhost:3001"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true, "foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
// TODO(tylersmalley) resolve these failures
// add(step{ // turn funnel off for primary port 443
// command: cmd("serve --https=443 --set-path=/bar localhost:3001"),
// want: &ipn.ServeConfig{
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
// TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
// Web: map[ipn.HostPort]*ipn.WebServerConfig{
// "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
// "/": {Proxy: "http://127.0.0.1:3000"},
// }},
// "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
// "/bar": {Proxy: "http://127.0.0.1:3001"},
// }},
// },
// },
// })
// add(step{ // remove secondary port
// command: cmd("https:8443 /bar off"),
// want: &ipn.ServeConfig{
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
// TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
// Web: map[ipn.HostPort]*ipn.WebServerConfig{
// "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
// "/": {Proxy: "http://127.0.0.1:3000"},
// }},
// },
// },
// })
// add(step{ // start a tcp forwarder on 8443
// command: cmd("tcp:8443 tcp://localhost:5432"),
// want: &ipn.ServeConfig{
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
// TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}},
// Web: map[ipn.HostPort]*ipn.WebServerConfig{
// "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
// "/": {Proxy: "http://127.0.0.1:3000"},
// }},
// },
// },
// })
// add(step{ // remove primary port http handler
// command: cmd("https:443 / off"),
// want: &ipn.ServeConfig{
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
// TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
// },
// })
// add(step{ // remove tcp forwarder
// command: cmd("tls-terminated-tcp:8443 off"),
// want: &ipn.ServeConfig{
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
// },
// })
// add(step{ // turn off funnel
// command: cmd("funnel 8443 off"),
// want: &ipn.ServeConfig{},
// })
// // tricky steps
add(step{reset: true})
add(step{ // a directory with a trailing slash mount point
command: cmd("serve --https=443 --set-path=/dir " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/dir/": {Path: filepath.Join(td, "subdir/")},
}},
},
},
})
add(step{ // this should overwrite the previous one
command: cmd("serve --https=443 --set-path=/dir " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/dir": {Path: filepath.Join(td, "foo")},
}},
},
},
})
add(step{reset: true}) // reset and do the opposite
add(step{ // a file without a trailing slash mount point
command: cmd("serve --https=443 --set-path=/dir " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/dir": {Path: filepath.Join(td, "foo")},
}},
},
},
})
add(step{ // this should overwrite the previous one
command: cmd("serve --https=443 --set-path=/dir " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/dir/": {Path: filepath.Join(td, "subdir/")},
}},
},
},
})
// // error states
add(step{reset: true})
add(step{ // tcp forward 5432 on serve port 443
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:5432",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{ // try to start a web handler on the same port
command: cmd("serve --https=443 --bg localhost:3000"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{reset: true})
add(step{ // start a web handler on port 443
command: cmd("serve --https=443 --bg localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // try to start a tcp forwarder on the same serve port
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"),
wantErr: anyErr(),
})
lc := &fakeLocalServeClient{}
// And now run the steps above.
for i, st := range steps {
if st.debugBreak != nil {
st.debugBreak()
}
if st.reset {
t.Logf("Executing step #%d, line %v: [reset]", i, st.line)
lc.config = nil
}
if st.command == nil {
continue
}
t.Logf("Executing step #%d, line %v: %q ... ", i, st.line, st.command)
var stdout bytes.Buffer
var flagOut bytes.Buffer
e := &serveEnv{
lc: lc,
testFlagOut: &flagOut,
testStdout: &stdout,
}
lastCount := lc.setCount
var cmd *ffcli.Command
var args []string
mode := serve
if st.command[0] == "funnel" {
mode = funnel
}
cmd = newServeDevCommand(e, mode)
args = st.command[1:]
err := cmd.ParseAndRun(context.Background(), args)
if flagOut.Len() > 0 {
t.Logf("flag package output: %q", flagOut.Bytes())
}
if err != nil {
if st.wantErr == nil {
t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, err)
}
if bad := st.wantErr(err); bad != "" {
t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, bad)
}
continue
}
if st.wantErr != nil {
t.Fatalf("step #%d, line %v: got success (saved=%v), but wanted an error", i, st.line, lc.config != nil)
}
var got *ipn.ServeConfig = nil
if lc.setCount > lastCount {
got = lc.config
}
if !reflect.DeepEqual(got, st.want) {
t.Fatalf("[%d] %v: bad state. got:\n%v\n\nwant:\n%v\n",
i, st.command, logger.AsJSON(got), logger.AsJSON(st.want))
// NOTE: asJSON will omit empty fields, which might make
// result in bad state got/want diffs being the same, even
// though the actual state is different. Use below to debug:
// t.Fatalf("[%d] %v: bad state. got:\n%+v\n\nwant:\n%+v\n",
// i, st.command, got, st.want)
}
}
}
func TestSrcTypeFromFlags(t *testing.T) {
tests := []struct {
name string
env *serveEnv
expectedType serveType
expectedPort uint16
expectedErr bool
}{
{
name: "only http set",
env: &serveEnv{http: "80"},
expectedType: serveTypeHTTP,
expectedPort: 80,
expectedErr: false,
},
{
name: "only https set",
env: &serveEnv{https: "10000"},
expectedType: serveTypeHTTPS,
expectedPort: 10000,
expectedErr: false,
},
{
name: "only tcp set",
env: &serveEnv{tcp: "8000"},
expectedType: serveTypeTCP,
expectedPort: 8000,
expectedErr: false,
},
{
name: "only tls-terminated-tcp set",
env: &serveEnv{tlsTerminatedTCP: "8080"},
expectedType: serveTypeTLSTerminatedTCP,
expectedPort: 8080,
expectedErr: false,
},
{
name: "defaults to https, port 443",
env: &serveEnv{},
expectedType: serveTypeHTTPS,
expectedPort: 443,
expectedErr: false,
},
{
name: "multiple types set",
env: &serveEnv{http: "80", https: "443"},
expectedPort: 0,
expectedErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srcType, srcPort, err := srvTypeAndPortFromFlags(tt.env)
if (err != nil) != tt.expectedErr {
t.Errorf("Expected error: %v, got: %v", tt.expectedErr, err)
}
if srcType != tt.expectedType {
t.Errorf("Expected srcType: %s, got: %s", tt.expectedType.String(), srcType)
}
if srcPort != tt.expectedPort {
t.Errorf("Expected srcPort: %d, got: %d", tt.expectedPort, srcPort)
}
})
}
}
func TestExpandProxyTargetDev(t *testing.T) {
tests := []struct {
input string
expected string
wantErr bool
}{
{input: "8080", expected: "http://127.0.0.1:8080"},
{input: "localhost:8080", expected: "http://127.0.0.1:8080"},
{input: "http://localhost:8080", expected: "http://127.0.0.1:8080"},
{input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"},
{input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"},
{input: "https://localhost:8080", expected: "https://127.0.0.1:8080"},
{input: "https+insecure://localhost:8080", expected: "https+insecure://127.0.0.1:8080"},
// errors
{input: "localhost:9999999", wantErr: true},
{input: "ftp://localhost:8080", expected: "", wantErr: true},
{input: "https://tailscale.com:8080", expected: "", wantErr: true},
{input: "", expected: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
actual, err := expandProxyTargetDev(tt.input)
if tt.wantErr == true && err == nil {
t.Errorf("Expected an error but got none")
return
}
if tt.wantErr == false && err != nil {
t.Errorf("Got an error, but didn't expect one: %v", err)
return
}
if actual != tt.expected {
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
}
})
}
}
func TestCleanURLPath(t *testing.T) {
tests := []struct {
input string
expected string
wantErr bool
}{
{input: "", expected: "/"},
{input: "/", expected: "/"},
{input: "/foo", expected: "/foo"},
{input: "/foo/", expected: "/foo/"},
{input: "/../bar", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
actual, err := cleanURLPath(tt.input)
if tt.wantErr == true && err == nil {
t.Errorf("Expected an error but got none")
return
}
if tt.wantErr == false && err != nil {
t.Errorf("Got an error, but didn't expect one: %v", err)
return
}
if actual != tt.expected {
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
}
})
}
}
func TestIsLegacyInvocation(t *testing.T) {
tests := []struct {
subcmd serveMode
args []string
expected bool
}{
{subcmd: serve, args: []string{"https", "localhost:3000"}, expected: true},
{subcmd: serve, args: []string{"https:8443", "localhost:3000"}, expected: true},
{subcmd: serve, args: []string{"http", "localhost:3000"}, expected: true},
{subcmd: serve, args: []string{"http:80", "localhost:3000"}, expected: true},
{subcmd: serve, args: []string{"tcp:2222", "tcp://localhost:22"}, expected: true},
{subcmd: serve, args: []string{"tls-terminated-tcp:443", "tcp://localhost:80"}, expected: true},
// false
{subcmd: serve, args: []string{"3000"}, expected: false},
{subcmd: serve, args: []string{"localhost:3000"}, expected: false},
}
for _, tt := range tests {
args := strings.Join(tt.args, " ")
t.Run(fmt.Sprintf("%v %s", infoMap[tt.subcmd].Name, args), func(t *testing.T) {
actual := isLegacyInvocation(tt.subcmd, tt.args)
if actual != tt.expected {
t.Errorf("Got: %v; expected: %v", actual, tt.expected)
}
})
}
}

View File

@@ -9,7 +9,6 @@ import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
@@ -339,19 +338,19 @@ func TestServeConfigMutations(t *testing.T) {
add(step{reset: true})
add(step{ // must include scheme for tcp
command: cmd("tls-terminated-tcp:443 localhost:5432"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{ // !somehost, must be localhost or 127.0.0.1
command: cmd("tls-terminated-tcp:443 tcp://somehost:5432"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{ // bad target port, too low
command: cmd("tls-terminated-tcp:443 tcp://somehost:0"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{ // bad target port, too high
command: cmd("tls-terminated-tcp:443 tcp://somehost:65536"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{
command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"),
@@ -472,7 +471,7 @@ func TestServeConfigMutations(t *testing.T) {
})
add(step{ // bad path
command: cmd("https:443 / bad/path"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{reset: true})
add(step{
@@ -666,7 +665,7 @@ func TestServeConfigMutations(t *testing.T) {
})
add(step{ // try to start a web handler on the same port
command: cmd("https:443 / localhost:3000"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
wantErr: exactErr(errHelp, "errHelp"),
})
add(step{reset: true})
add(step{ // start a web handler on port 443
@@ -902,11 +901,6 @@ func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name strin
return nil // unused in tests
}
func (lc *fakeLocalServeClient) StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) {
// TODO: testing :)
return nil, nil
}
// 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

@@ -11,6 +11,7 @@ import (
"net/netip"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/clientupdate"
"tailscale.com/ipn"
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
@@ -46,6 +47,8 @@ type setArgsT struct {
acceptedRisks string
profileName string
forceDaemon bool
updateCheck bool
updateApply bool
}
func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
@@ -61,6 +64,8 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
setf.StringVar(&setArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
setf.BoolVar(&setArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "HIDDEN: notify about available Tailscale updates")
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "HIDDEN: automatically update to the latest available version")
if safesocket.GOOSUsesPeerCreds(goos) {
setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
}
@@ -99,6 +104,10 @@ func runSet(ctx context.Context, args []string) (retErr error) {
Hostname: setArgs.hostname,
OperatorUser: setArgs.opUser,
ForceDaemon: setArgs.forceDaemon,
AutoUpdate: ipn.AutoUpdatePrefs{
Check: setArgs.updateCheck,
Apply: setArgs.updateApply,
},
},
}
@@ -143,6 +152,12 @@ func runSet(ctx context.Context, args []string) (retErr error) {
return err
}
}
if maskedPrefs.AutoUpdateSet {
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
if errors.Is(err, errors.ErrUnsupported) {
return errors.New("automatic updates are not supported on this platform")
}
}
checkPrefs := curPrefs.Clone()
checkPrefs.ApplyEdits(maskedPrefs)
if err := localClient.CheckPrefs(ctx, checkPrefs); err != nil {

View File

@@ -236,6 +236,9 @@ func runStatus(ctx context.Context, args []string) error {
printHealth()
}
printFunnelStatus(ctx)
if cv := st.ClientVersion; cv != nil && !cv.RunningLatest && cv.LatestVersion != "" {
printf("# New Tailscale version is available: %q, run `tailscale update` to update.\n", cv.LatestVersion)
}
return nil
}

View File

@@ -97,6 +97,8 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
}
upf := newFlagSet(cmd)
// When adding new flags, prefer to put them under "tailscale set" instead
// of here. Setting preferences via "tailscale up" is deprecated.
upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs")
upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`)
@@ -712,6 +714,8 @@ func init() {
addPrefFlagMapping("operator", "OperatorUser")
addPrefFlagMapping("ssh", "RunSSH")
addPrefFlagMapping("nickname", "ProfileName")
addPrefFlagMapping("update-check", "AutoUpdate")
addPrefFlagMapping("auto-update", "AutoUpdate")
}
func addPrefFlagMapping(flagName string, prefNames ...string) {

View File

@@ -60,7 +60,7 @@ func runUpdate(ctx context.Context, args []string) error {
if updateArgs.track != "" {
ver = updateArgs.track
}
err := clientupdate.Update(clientupdate.UpdateArgs{
err := clientupdate.Update(clientupdate.Arguments{
Version: ver,
AppStore: updateArgs.appStore,
Logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) },

View File

@@ -39,6 +39,7 @@ Tailscale, as opposed to a CLI or a native app.
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]")
webf.StringVar(&webArgs.prefix, "prefix", "", "URL prefix added to requests (for cgi or reverse proxies)")
return webf
})(),
Exec: runWeb,
@@ -48,6 +49,7 @@ var webArgs struct {
listen string
cgi bool
dev bool
prefix string
}
func tlsConfigFromEnvironment() *tls.Config {
@@ -81,6 +83,7 @@ func runWeb(ctx context.Context, args []string) error {
webServer, cleanup := web.NewServer(ctx, web.ServerOpts{
DevMode: webArgs.dev,
CGIMode: webArgs.cgi,
PathPrefix: webArgs.prefix,
LocalClient: &localClient,
})
defer cleanup()

View File

@@ -11,9 +11,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
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
L github.com/coreos/go-systemd/v22/dbus from tailscale.com/clientupdate
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
L 💣 github.com/godbus/dbus/v5 from github.com/coreos/go-systemd/v22/dbus
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
@@ -200,11 +202,12 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
golang.org/x/text/unicode/bidi from golang.org/x/net/idna+
golang.org/x/text/unicode/norm from golang.org/x/net/idna
golang.org/x/time/rate from tailscale.com/cmd/tailscale/cli+
archive/tar from tailscale.com/clientupdate
bufio from compress/flate+
bytes from bufio+
cmp from slices
compress/flate from compress/gzip+
compress/gzip from net/http
compress/gzip from net/http+
compress/zlib from image/png+
container/list from crypto/tls+
context from crypto/tls+

View File

@@ -76,6 +76,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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/util/linuxfw
L github.com/coreos/go-systemd/v22/dbus from tailscale.com/clientupdate
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/com from tailscale.com/cmd/tailscaled+
@@ -94,8 +95,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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/ipn/ipnlocal
github.com/hdevalence/ed25519consensus from tailscale.com/tka
github.com/google/uuid from tailscale.com/clientupdate
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
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
@@ -216,6 +217,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
tailscale.com/client/tailscale from tailscale.com/derp
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+
tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+
tailscale.com/control/controlbase from tailscale.com/control/controlclient+
tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+
@@ -287,7 +290,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/paths from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/smallzstd from tailscale.com/cmd/tailscaled+
tailscale.com/smallzstd from tailscale.com/control/controlclient+
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
tailscale.com/syncs from tailscale.com/net/netcheck+
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+
@@ -302,7 +305,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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+
tailscale.com/types/empty from tailscale.com/control/controlclient+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/control/controlbase+
@@ -312,7 +315,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/types/netlogtype from tailscale.com/net/connstats+
tailscale.com/types/netmap from tailscale.com/control/controlclient+
tailscale.com/types/nettype from tailscale.com/wgengine/magicsock+
tailscale.com/types/opt from tailscale.com/control/controlclient+
tailscale.com/types/opt from tailscale.com/client/tailscale+
tailscale.com/types/persist from tailscale.com/control/controlclient+
tailscale.com/types/preftype from tailscale.com/ipn+
tailscale.com/types/ptr from tailscale.com/hostinfo+
@@ -334,12 +337,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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/must from tailscale.com/logpolicy+
💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+
W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag
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
tailscale.com/util/rands from tailscale.com/ipn/localapi+
tailscale.com/util/ringbuffer from tailscale.com/wgengine/magicsock
tailscale.com/util/set from tailscale.com/health+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
@@ -349,7 +353,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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/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+
@@ -411,6 +415,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/text/unicode/bidi from golang.org/x/net/idna+
golang.org/x/text/unicode/norm from golang.org/x/net/idna
golang.org/x/time/rate from gvisor.dev/gvisor/pkg/tcpip/stack+
archive/tar from tailscale.com/clientupdate
bufio from compress/flate+
bytes from bufio+
cmp from slices

View File

@@ -48,7 +48,6 @@ import (
"tailscale.com/net/tstun"
"tailscale.com/paths"
"tailscale.com/safesocket"
"tailscale.com/smallzstd"
"tailscale.com/syncs"
"tailscale.com/tsd"
"tailscale.com/tsweb/varz"
@@ -76,6 +75,8 @@ func defaultTunName() string {
// "utun" is recognized by wireguard-go/tun/tun_darwin.go
// as a magic value that uses/creates any free number.
return "utun"
case "plan9":
return "userspace-networking"
case "linux":
switch distro.Get() {
case distro.Synology:
@@ -200,6 +201,10 @@ func main() {
}
}
if fd, ok := envknob.LookupInt("TS_PARENT_DEATH_FD"); ok && fd > 2 {
go dieOnPipeReadErrorOfFD(fd)
}
if printVersion {
fmt.Println(version.String())
os.Exit(0)
@@ -491,6 +496,7 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
if err != nil {
return nil, fmt.Errorf("newNetstack: %w", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = onlyNetstack
ns.ProcessSubnets = onlyNetstack || handleSubnetsInNetstack()
@@ -545,9 +551,6 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
if root := lb.TailscaleVarRoot(); root != "" {
dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"), logf)
}
lb.SetDecompressor(func() (controlclient.Decompressor, error) {
return smallzstd.NewDecoder(nil)
})
configureTaildrop(logf, lb)
if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err)
@@ -605,6 +608,7 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
NetMon: sys.NetMon.Get(),
Dialer: sys.Dialer.Get(),
SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(),
}
onlyNetstack = name == "userspace-networking"
@@ -767,3 +771,14 @@ func beChild(args []string) error {
}
return f(args[1:])
}
// dieOnPipeReadErrorOfFD reads from the pipe named by fd and exit the process
// when the pipe becomes readable. We use this in tests as a somewhat more
// portable mechanism for the Linux PR_SET_PDEATHSIG, which we wish existed on
// macOS. This helps us clean up straggler tailscaled processes when the parent
// test driver dies unexpectedly.
func dieOnPipeReadErrorOfFD(fd int) {
f := os.NewFile(uintptr(fd), "TS_PARENT_DEATH_FD")
f.Read(make([]byte, 1))
os.Exit(1)
}

View File

@@ -19,7 +19,8 @@ import (
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.
// when a flaky test is being (re)tried. 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`)
@@ -33,7 +34,11 @@ func Mark(t testing.TB, issue string) {
if !issueRegexp.MatchString(issue) {
t.Fatalf("bad issue format: %q", issue)
}
fmt.Fprintln(os.Stderr, FlakyTestLogMessage) // sentinel value for testwrapper
if _, ok := os.LookupEnv(FlakeAttemptEnv); ok {
// We're being run under cmd/testwrapper so send our sentinel message
// to stderr. (We avoid doing this when the env is absent to avoid
// spamming people running tests without the wrapper)
fmt.Fprintln(os.Stderr, FlakyTestLogMessage)
}
t.Logf("flakytest: issue tracking this flaky test: %s", issue)
}

View File

@@ -8,6 +8,7 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -29,7 +30,8 @@ import (
const maxAttempts = 3
type testAttempt struct {
name testName
pkg string // "tailscale.com/types/key"
testName string // "TestFoo"
outcome string // "pass", "fail", "skip"
logs bytes.Buffer
isMarkedFlaky bool // set if the test is marked as flaky
@@ -37,11 +39,6 @@ type testAttempt struct {
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.
@@ -63,9 +60,10 @@ 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.
// set to true. Package build errors will not emit a testAttempt (as no valid
// JSON is produced) but the [os/exec.ExitError] will be returned.
// It calls close(ch) when it's done.
func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []string, ch chan<- *testAttempt) {
func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []string, ch chan<- *testAttempt) error {
defer close(ch)
args := []string{"test", "-json", pt.pattern}
args = append(args, otherArgs...)
@@ -91,17 +89,12 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []st
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 {
s := bufio.NewScanner(r)
resultMap := make(map[string]map[string]*testAttempt) // pkg -> test -> testAttempt
for s.Scan() {
var goOutput goTestOutput
if err := jd.Decode(&goOutput); err != nil {
if err := json.Unmarshal(s.Bytes(), &goOutput); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
break
}
@@ -111,32 +104,39 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []st
// 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)
fmt.Println(s.Text())
continue
}
panic(err)
}
pkg := goOutput.Package
pkgTests := resultMap[pkg]
if goOutput.Test == "" {
switch goOutput.Action {
case "fail", "pass", "skip":
for _, test := range pkgTests {
if test.outcome == "" {
test.outcome = "fail"
ch <- test
}
}
ch <- &testAttempt{
name: testName{
pkg: goOutput.Package,
},
pkg: goOutput.Package,
outcome: goOutput.Action,
pkgFinished: true,
}
}
continue
}
name := testName{
pkg: goOutput.Package,
name: goOutput.Test,
if pkgTests == nil {
pkgTests = make(map[string]*testAttempt)
resultMap[pkg] = pkgTests
}
testName := goOutput.Test
if test, _, isSubtest := strings.Cut(goOutput.Test, "/"); isSubtest {
name.name = test
testName = test
if goOutput.Action == "output" {
resultMap[name].logs.WriteString(goOutput.Output)
resultMap[pkg][testName].logs.WriteString(goOutput.Output)
}
continue
}
@@ -144,21 +144,28 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []st
case "start":
// ignore
case "run":
resultMap[name] = &testAttempt{
name: name,
pkgTests[testName] = &testAttempt{
pkg: pkg,
testName: testName,
}
case "skip", "pass", "fail":
resultMap[name].outcome = goOutput.Action
ch <- resultMap[name]
pkgTests[testName].outcome = goOutput.Action
ch <- pkgTests[testName]
case "output":
if strings.TrimSpace(goOutput.Output) == flakytest.FlakyTestLogMessage {
resultMap[name].isMarkedFlaky = true
pkgTests[testName].isMarkedFlaky = true
} else {
resultMap[name].logs.WriteString(goOutput.Output)
pkgTests[testName].logs.WriteString(goOutput.Output)
}
}
}
<-done
if err := cmd.Wait(); err != nil {
return err
}
if err := s.Err(); err != nil {
return fmt.Errorf("reading go test stdout: %w", err)
}
return nil
}
func main() {
@@ -240,14 +247,32 @@ func main() {
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)
runErr := make(chan error, 1)
go func() {
defer close(runErr)
runErr <- runTests(ctx, thisRun.attempt, pt, otherArgs, ch)
}()
var failed bool
for tr := range ch {
// Go assigns the package name "command-line-arguments" when you
// `go test FILE` rather than `go test PKG`. It's more
// convenient for us to to specify files in tests, so fix tr.pkg
// so that subsequent testwrapper attempts run correctly.
if tr.pkg == "command-line-arguments" {
tr.pkg = pattern
}
if tr.pkgFinished {
printPkgOutcome(tr.name.pkg, tr.outcome, thisRun.attempt)
if tr.outcome == "fail" && len(toRetry[tr.pkg]) == 0 {
// If a package fails and we don't have any tests to
// retry, then we should fail. This typically happens
// when a package times out.
failed = true
}
printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt)
continue
}
if *v || tr.outcome == "fail" {
@@ -257,15 +282,28 @@ func main() {
continue
}
if tr.isMarkedFlaky {
toRetry[tr.name.pkg] = append(toRetry[tr.name.pkg], tr.name.name)
toRetry[tr.pkg] = append(toRetry[tr.pkg], tr.testName)
} else {
failed = true
}
}
}
if failed {
fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
os.Exit(1)
if failed {
fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
os.Exit(1)
}
// If there's nothing to retry and no non-retryable tests have
// failed then we've probably hit a build error.
if err := <-runErr; len(toRetry) == 0 && err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
if code := exit.ExitCode(); code > -1 {
os.Exit(exit.ExitCode())
}
}
log.Printf("testwrapper: %s", err)
os.Exit(1)
}
}
if len(toRetry) == 0 {
continue

View File

@@ -0,0 +1,218 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main_test
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
)
var (
buildPath string
buildErr error
buildOnce sync.Once
)
func cmdTestwrapper(t *testing.T, args ...string) *exec.Cmd {
buildOnce.Do(func() {
buildPath, buildErr = buildTestWrapper()
})
if buildErr != nil {
t.Fatalf("building testwrapper: %s", buildErr)
}
return exec.Command(buildPath, args...)
}
func buildTestWrapper() (string, error) {
dir, err := os.MkdirTemp("", "testwrapper")
if err != nil {
return "", fmt.Errorf("making temp dir: %w", err)
}
_, err = exec.Command("go", "build", "-o", dir, ".").Output()
if err != nil {
return "", fmt.Errorf("go build: %w", err)
}
return filepath.Join(dir, "testwrapper"), nil
}
func TestRetry(t *testing.T) {
t.Parallel()
testfile := filepath.Join(t.TempDir(), "retry_test.go")
code := []byte(`package retry_test
import (
"os"
"testing"
"tailscale.com/cmd/testwrapper/flakytest"
)
func TestOK(t *testing.T) {}
func TestFlakeRun(t *testing.T) {
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue
e := os.Getenv(flakytest.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.")
}
}
`)
if err := os.WriteFile(testfile, code, 0o644); err != nil {
t.Fatalf("writing package: %s", err)
}
out, err := cmdTestwrapper(t, "-v", testfile).CombinedOutput()
if err != nil {
t.Fatalf("go run . %s: %s with output:\n%s", testfile, err, out)
}
want := []byte("ok\t" + testfile + " [attempt=2]")
if !bytes.Contains(out, want) {
t.Fatalf("wanted output containing %q but got:\n%s", want, out)
}
if okRuns := bytes.Count(out, []byte("=== RUN TestOK")); okRuns != 1 {
t.Fatalf("expected TestOK to be run once but was run %d times in output:\n%s", okRuns, out)
}
if flakeRuns := bytes.Count(out, []byte("=== RUN TestFlakeRun")); flakeRuns != 2 {
t.Fatalf("expected TestFlakeRun to be run twice but was run %d times in output:\n%s", flakeRuns, out)
}
if testing.Verbose() {
t.Logf("success - output:\n%s", out)
}
}
func TestNoRetry(t *testing.T) {
t.Parallel()
testfile := filepath.Join(t.TempDir(), "noretry_test.go")
code := []byte(`package noretry_test
import (
"testing"
"tailscale.com/cmd/testwrapper/flakytest"
)
func TestFlakeRun(t *testing.T) {
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue
t.Error("shouldn't be retried")
}
func TestAlwaysError(t *testing.T) {
t.Error("error")
}
`)
if err := os.WriteFile(testfile, code, 0o644); err != nil {
t.Fatalf("writing package: %s", err)
}
out, err := cmdTestwrapper(t, "-v", testfile).Output()
if err == nil {
t.Fatalf("go run . %s: expected error but it succeeded with output:\n%s", testfile, out)
}
if code, ok := errExitCode(err); ok && code != 1 {
t.Fatalf("expected exit code 1 but got %d", code)
}
want := []byte("Not retrying flaky tests because non-flaky tests failed.")
if !bytes.Contains(out, want) {
t.Fatalf("wanted output containing %q but got:\n%s", want, out)
}
if flakeRuns := bytes.Count(out, []byte("=== RUN TestFlakeRun")); flakeRuns != 1 {
t.Fatalf("expected TestFlakeRun to be run once but was run %d times in output:\n%s", flakeRuns, out)
}
if testing.Verbose() {
t.Logf("success - output:\n%s", out)
}
}
func TestBuildError(t *testing.T) {
t.Parallel()
// Construct our broken package.
testfile := filepath.Join(t.TempDir(), "builderror_test.go")
code := []byte("package builderror_test\n\nderp")
err := os.WriteFile(testfile, code, 0o644)
if err != nil {
t.Fatalf("writing package: %s", err)
}
buildErr := []byte("builderror_test.go:3:1: expected declaration, found derp\nFAIL command-line-arguments [setup failed]")
// Confirm `go test` exits with code 1.
goOut, err := exec.Command("go", "test", testfile).CombinedOutput()
if code, ok := errExitCode(err); !ok || code != 1 {
t.Fatalf("go test %s: expected error with exit code 0 but got: %v", testfile, err)
}
if !bytes.Contains(goOut, buildErr) {
t.Fatalf("go test %s: expected build error containing %q but got:\n%s", testfile, buildErr, goOut)
}
// Confirm `testwrapper` exits with code 1.
twOut, err := cmdTestwrapper(t, testfile).CombinedOutput()
if code, ok := errExitCode(err); !ok || code != 1 {
t.Fatalf("testwrapper %s: expected error with exit code 0 but got: %v", testfile, err)
}
if !bytes.Contains(twOut, buildErr) {
t.Fatalf("testwrapper %s: expected build error containing %q but got:\n%s", testfile, buildErr, twOut)
}
if testing.Verbose() {
t.Logf("success - output:\n%s", twOut)
}
}
func TestTimeout(t *testing.T) {
t.Parallel()
// Construct our broken package.
testfile := filepath.Join(t.TempDir(), "timeout_test.go")
code := []byte(`package noretry_test
import (
"testing"
"time"
)
func TestTimeout(t *testing.T) {
time.Sleep(500 * time.Millisecond)
}
`)
err := os.WriteFile(testfile, code, 0o644)
if err != nil {
t.Fatalf("writing package: %s", err)
}
out, err := cmdTestwrapper(t, testfile, "-timeout=20ms").CombinedOutput()
if code, ok := errExitCode(err); !ok || code != 1 {
t.Fatalf("testwrapper %s: expected error with exit code 0 but got: %v; output was:\n%s", testfile, err, out)
}
if want := "panic: test timed out after 20ms"; !bytes.Contains(out, []byte(want)) {
t.Fatalf("testwrapper %s: expected build error containing %q but got:\n%s", testfile, buildErr, out)
}
if testing.Verbose() {
t.Logf("success - output:\n%s", out)
}
}
func errExitCode(err error) (int, bool) {
var exit *exec.ExitError
if errors.As(err, &exit) {
return exit.ExitCode(), true
}
return 0, false
}

View File

@@ -35,7 +35,6 @@ import (
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/smallzstd"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/wgengine"
@@ -103,6 +102,7 @@ func newIPN(jsConfig js.Value) map[string]any {
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(),
})
if err != nil {
log.Fatal(err)
@@ -113,6 +113,7 @@ func newIPN(jsConfig js.Value) map[string]any {
if err != nil {
log.Fatalf("netstack.Create: %v", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = true
ns.ProcessSubnets = true
@@ -133,9 +134,6 @@ func newIPN(jsConfig js.Value) map[string]any {
if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err)
}
lb.SetDecompressor(func() (controlclient.Decompressor, error) {
return smallzstd.NewDecoder(nil)
})
srv.SetLocalBackend(lb)
jsIPN := &jsIPN{
@@ -326,7 +324,11 @@ func (i *jsIPN) logout() {
if i.lb.State() == ipn.NoState {
log.Printf("Backend not running")
}
go i.lb.Logout()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
i.lb.Logout(ctx)
}()
}
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {

View File

@@ -17,54 +17,36 @@ import (
"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"
)
type LoginGoal struct {
_ structs.Incomparable
wantLoggedIn bool // true if we *want* to be logged in
token *tailcfg.Oauth2Token // oauth token to use when logging in
flags LoginFlags // flags to use when logging in
url string // auth url that needs to be visited
loggedOutResult chan<- error
}
func (g *LoginGoal) sendLogoutError(err error) {
if g.loggedOutResult == nil {
return
}
select {
case g.loggedOutResult <- err:
default:
}
_ structs.Incomparable
token *tailcfg.Oauth2Token // oauth token to use when logging in
flags LoginFlags // flags to use when logging in
url string // auth url that needs to be visited
}
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 {
// waitUnpause waits until either the client is unpaused or the Auto client is
// shut down. It reports whether the client should keep running (i.e. it's not
// closed).
func (c *Auto) waitUnpause(routineLogName string) (keepRunning bool) {
c.mu.Lock()
if !c.paused {
c.mu.Unlock()
return nil
if !c.paused || c.closed {
defer c.mu.Unlock()
return !c.closed
}
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")
}
return <-unpaused
}
// updateRoutine is responsible for informing the server of worthy changes to
@@ -78,7 +60,7 @@ func (c *Auto) updateRoutine() {
var lastUpdateGenInformed updateGen
for {
if err := c.waitUnpause("updateRoutine"); err != nil {
if !c.waitUnpause("updateRoutine") {
c.logf("updateRoutine: exiting")
return
}
@@ -88,19 +70,11 @@ func (c *Auto) updateRoutine() {
needUpdate := gen > 0 && gen != lastUpdateGenInformed && c.loggedIn
c.mu.Unlock()
if needUpdate {
select {
case <-c.quit:
c.logf("updateRoutine: exiting")
return
default:
}
} else {
if !needUpdate {
// Nothing to do, wait for a signal.
select {
case <-c.quit:
c.logf("updateRoutine: exiting")
return
case <-ctx.Done():
continue
case <-c.updateCh:
continue
}
@@ -138,36 +112,37 @@ 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
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
observer Observer // called to update Client status; always non-nil
direct *Direct // our interface to the server APIs
clock tstime.Clock
logf logger.Logf
closed bool
updateCh chan struct{} // readable when we should inform the server of a change
observer Observer // called to update Client status; always non-nil
observerQueue execQueue
unregisterHealthWatch func()
mu sync.Mutex // mutex guards the following fields
wantLoggedIn bool // whether the user wants to be logged in per last method call
urlToVisit string // the last url we were told to visit
expiry time.Time
// 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
paused bool // whether we should stop making HTTP requests
unpauseWaiters []chan bool // chans that gets sent true (once) on wake, or false on Shutdown
loggedIn bool // true if currently logged in
loginGoal *LoginGoal // non-nil if some login activity is desired
inMapPoll bool // true once we get the first MapResponse in a stream; false when HTTP response ends
state State // TODO(bradfitz): delete this, make it computed by method from other state
authCtx context.Context // context used for auth requests
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, authRoutine is done
mapDone chan struct{} // when closed, mapRoutine is done
updateDone chan struct{} // when closed, updateRoutine is done
@@ -208,8 +183,6 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
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{}),
@@ -232,21 +205,20 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
func (c *Auto) SetPaused(paused bool) {
c.mu.Lock()
defer c.mu.Unlock()
if paused == c.paused {
if paused == c.paused || c.closed {
return
}
c.logf("setPaused(%v)", paused)
c.paused = paused
if paused {
// Only cancel the map routine. (The auth routine isn't expensive
// so it's fine to keep it running.)
c.cancelMapLocked()
} else {
for _, ch := range c.unpauseWaiters {
close(ch)
}
c.unpauseWaiters = nil
c.cancelMapCtxLocked()
c.cancelAuthCtxLocked()
return
}
for _, ch := range c.unpauseWaiters {
ch <- true
}
c.unpauseWaiters = nil
}
// Start starts the client's goroutines.
@@ -280,9 +252,16 @@ func (c *Auto) updateControl() {
}
}
func (c *Auto) cancelAuth() {
// cancelAuthCtx cancels the existing auth goroutine's context
// & creates a new one, causing it to restart.
func (c *Auto) cancelAuthCtx() {
c.mu.Lock()
defer c.mu.Unlock()
c.cancelAuthCtxLocked()
}
// cancelAuthCtxLocked is like cancelAuthCtx, but assumes the caller holds c.mu.
func (c *Auto) cancelAuthCtxLocked() {
if c.authCancel != nil {
c.authCancel()
}
@@ -292,8 +271,16 @@ func (c *Auto) cancelAuth() {
}
}
// cancelMapLocked is like cancelMap, but assumes the caller holds c.mu.
func (c *Auto) cancelMapLocked() {
// cancelMapCtx cancels the context for the existing mapPoll and liteUpdates
// goroutines and creates a new one, causing them to restart.
func (c *Auto) cancelMapCtx() {
c.mu.Lock()
defer c.mu.Unlock()
c.cancelMapCtxLocked()
}
// cancelMapCtxLocked is like cancelMapCtx, but assumes the caller holds c.mu.
func (c *Auto) cancelMapCtxLocked() {
if c.mapCancel != nil {
c.mapCancel()
}
@@ -303,32 +290,15 @@ func (c *Auto) cancelMapLocked() {
}
}
// cancelMap cancels the existing mapPoll and liteUpdates.
func (c *Auto) cancelMap() {
c.mu.Lock()
defer c.mu.Unlock()
c.cancelMapLocked()
}
// 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.cancelMapCtxLocked()
synced := c.inMapPoll
c.mu.Unlock()
c.logf("[v1] restartMap: synced=%v", synced)
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()
}
@@ -337,23 +307,20 @@ func (c *Auto) authRoutine() {
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
for {
if !c.waitUnpause("authRoutine") {
c.logf("authRoutine: exiting")
return
}
c.mu.Lock()
goal := c.loginGoal
ctx := c.authCtx
if goal != nil {
c.logf("[v1] authRoutine: %s; wantLoggedIn=%v", c.state, goal.wantLoggedIn)
c.logf("[v1] authRoutine: %s; wantLoggedIn=%v", c.state, true)
} else {
c.logf("[v1] authRoutine: %s; goal=nil paused=%v", c.state, c.paused)
}
c.mu.Unlock()
select {
case <-c.quit:
c.logf("[v1] authRoutine: quit")
return
default:
}
report := func(err error, msg string) {
c.logf("[v1] %s: %v", msg, err)
// don't send status updates for context errors,
@@ -371,111 +338,90 @@ func (c *Auto) authRoutine() {
continue
}
if !goal.wantLoggedIn {
health.SetAuthRoutineInError(nil)
err := c.direct.TryLogout(ctx)
goal.sendLogoutError(err)
if err != nil {
report(err, "TryLogout")
bo.BackOff(ctx, err)
continue
}
// success
c.mu.Lock()
c.loggedIn = false
c.loginGoal = nil
c.state = StateNotAuthenticated
c.synced = false
c.mu.Unlock()
c.sendStatus("authRoutine-wantout", nil, "", nil)
bo.BackOff(ctx, nil)
} else { // ie. goal.wantLoggedIn
c.mu.Lock()
if goal.url != "" {
c.state = StateURLVisitRequired
} else {
c.state = StateAuthenticating
}
c.mu.Unlock()
var url string
var err error
var f string
if goal.url != "" {
url, err = c.direct.WaitLoginURL(ctx, goal.url)
f = "WaitLoginURL"
} else {
url, err = c.direct.TryLogin(ctx, goal.token, goal.flags)
f = "TryLogin"
}
if err != nil {
health.SetAuthRoutineInError(err)
report(err, f)
bo.BackOff(ctx, err)
continue
}
if url != "" {
// goal.url ought to be empty here.
// However, not all control servers get this right,
// and logging about it here just generates noise.
c.mu.Lock()
c.loginGoal = &LoginGoal{
wantLoggedIn: true,
flags: LoginDefault,
url: url,
}
c.state = StateURLVisitRequired
c.synced = false
c.mu.Unlock()
c.sendStatus("authRoutine-url", err, url, nil)
if goal.url == url {
// The server sent us the same URL we already tried,
// backoff to avoid a busy loop.
bo.BackOff(ctx, errors.New("login URL not changing"))
} else {
bo.BackOff(ctx, nil)
}
continue
}
// success
health.SetAuthRoutineInError(nil)
c.mu.Lock()
c.loggedIn = true
c.loginGoal = nil
c.state = StateAuthenticated
c.mu.Unlock()
c.sendStatus("authRoutine-success", nil, "", nil)
c.restartMap()
bo.BackOff(ctx, nil)
c.mu.Lock()
c.urlToVisit = goal.url
if goal.url != "" {
c.state = StateURLVisitRequired
} else {
c.state = StateAuthenticating
}
c.mu.Unlock()
var url string
var err error
var f string
if goal.url != "" {
url, err = c.direct.WaitLoginURL(ctx, goal.url)
f = "WaitLoginURL"
} else {
url, err = c.direct.TryLogin(ctx, goal.token, goal.flags)
f = "TryLogin"
}
if err != nil {
health.SetAuthRoutineInError(err)
report(err, f)
bo.BackOff(ctx, err)
continue
}
if url != "" {
// goal.url ought to be empty here.
// However, not all control servers get this right,
// and logging about it here just generates noise.
c.mu.Lock()
c.urlToVisit = url
c.loginGoal = &LoginGoal{
flags: LoginDefault,
url: url,
}
c.state = StateURLVisitRequired
c.mu.Unlock()
c.sendStatus("authRoutine-url", err, url, nil)
if goal.url == url {
// The server sent us the same URL we already tried,
// backoff to avoid a busy loop.
bo.BackOff(ctx, errors.New("login URL not changing"))
} else {
bo.BackOff(ctx, nil)
}
continue
}
// success
health.SetAuthRoutineInError(nil)
c.mu.Lock()
c.urlToVisit = ""
c.loggedIn = true
c.loginGoal = nil
c.state = StateAuthenticated
c.mu.Unlock()
c.sendStatus("authRoutine-success", nil, "", nil)
c.restartMap()
bo.BackOff(ctx, nil)
}
}
// Expiry returns the credential expiration time, or the zero time if
// the expiration time isn't known. Used in tests only.
func (c *Auto) Expiry() *time.Time {
// ExpiryForTests returns the credential expiration time, or the zero value if
// the expiration time isn't known. It's used in tests only.
func (c *Auto) ExpiryForTests() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.expiry
}
// Direct returns the underlying direct client object. Used in tests
// only.
func (c *Auto) Direct() *Direct {
// DirectForTest returns the underlying direct client object.
// It's used in tests only.
func (c *Auto) DirectForTest() *Direct {
return c.direct
}
// unpausedChanLocked returns a new channel that is closed when the
// current Auto pause is unpaused.
// unpausedChanLocked returns a new channel that gets sent
// either a true when unpaused or false on Auto.Shutdown.
//
// c.mu must be held
func (c *Auto) unpausedChanLocked() <-chan struct{} {
unpaused := make(chan struct{})
func (c *Auto) unpausedChanLocked() <-chan bool {
unpaused := make(chan bool, 1)
c.unpauseWaiters = append(c.unpauseWaiters, unpaused)
return unpaused
}
@@ -486,17 +432,18 @@ type mapRoutineState struct {
bo *backoff.Backoff
}
var _ NetmapDeltaUpdater = mapRoutineState{}
func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
c := mrs.c
health.SetInPollNetMap(true)
c.mu.Lock()
ctx := c.mapCtx
c.synced = true
c.inMapPoll = true
if c.loggedIn {
c.state = StateSynchronized
}
c.expiry = ptr.To(nm.Expiry)
c.expiry = nm.Expiry
stillAuthed := c.loggedIn
c.logf("[v1] mapRoutine: netmap received: %s", c.state)
c.mu.Unlock()
@@ -508,6 +455,28 @@ func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
mrs.bo.BackOff(ctx, nil)
}
func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool {
c := mrs.c
c.mu.Lock()
goodState := c.loggedIn && c.inMapPoll
ndu, canDelta := c.observer.(NetmapDeltaUpdater)
c.mu.Unlock()
if !goodState || !canDelta {
return false
}
ctx, cancel := context.WithTimeout(c.mapCtx, 2*time.Second)
defer cancel()
var ok bool
err := c.observerQueue.RunSync(ctx, func() {
ok = ndu.UpdateNetmapDelta(muts)
})
return err == nil && ok
}
// 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() {
@@ -518,7 +487,7 @@ func (c *Auto) mapRoutine() {
}
for {
if err := c.waitUnpause("mapRoutine"); err != nil {
if !c.waitUnpause("mapRoutine") {
c.logf("mapRoutine: exiting")
return
}
@@ -529,13 +498,6 @@ func (c *Auto) mapRoutine() {
ctx := c.mapCtx
c.mu.Unlock()
select {
case <-c.quit:
c.logf("mapRoutine: quit")
return
default:
}
report := func(err error, msg string) {
c.logf("[v1] %s: %v", msg, err)
err = fmt.Errorf("%s: %w", msg, err)
@@ -549,40 +511,33 @@ func (c *Auto) mapRoutine() {
if !loggedIn {
// Wait for something interesting to happen
c.mu.Lock()
c.synced = false
// c.state is set by authRoutine()
c.inMapPoll = false
c.mu.Unlock()
select {
case <-ctx.Done():
c.logf("[v1] mapRoutine: context done.")
case <-c.newMapCh:
c.logf("[v1] mapRoutine: new map needed while idle.")
}
} else {
health.SetInPollNetMap(false)
err := c.direct.PollNetMap(ctx, mrs)
health.SetInPollNetMap(false)
c.mu.Lock()
c.synced = false
if c.state == StateSynchronized {
c.state = StateAuthenticated
}
paused := c.paused
c.mu.Unlock()
if paused {
mrs.bo.BackOff(ctx, nil)
c.logf("mapRoutine: paused")
continue
}
report(err, "PollNetMap")
mrs.bo.BackOff(ctx, err)
<-ctx.Done()
c.logf("[v1] mapRoutine: context done.")
continue
}
health.SetOutOfPollNetMap()
err := c.direct.PollNetMap(ctx, mrs)
health.SetOutOfPollNetMap()
c.mu.Lock()
c.inMapPoll = false
if c.state == StateSynchronized {
c.state = StateAuthenticated
}
paused := c.paused
c.mu.Unlock()
if paused {
mrs.bo.BackOff(ctx, nil)
c.logf("mapRoutine: paused")
} else {
mrs.bo.BackOff(ctx, err)
report(err, "PollNetMap")
}
}
}
@@ -631,6 +586,7 @@ func (c *Auto) SetTKAHead(headHash string) {
c.updateControl()
}
// sendStatus can not be called with the c.mu held.
func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkMap) {
c.mu.Lock()
if c.closed {
@@ -639,91 +595,77 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
}
state := c.state
loggedIn := c.loggedIn
synced := c.synced
c.inSendStatus++
inMapPoll := c.inMapPoll
c.mu.Unlock()
c.logf("[v1] sendStatus: %s: %v", who, state)
var p *persist.PersistView
var loginFin, logoutFin *empty.Message
if state == StateAuthenticated {
loginFin = new(empty.Message)
}
if state == StateNotAuthenticated {
logoutFin = new(empty.Message)
}
if nm != nil && loggedIn && synced {
p = ptr.To(c.direct.GetPersist())
var p persist.PersistView
if nm != nil && loggedIn && inMapPoll {
p = c.direct.GetPersist()
} else {
// don't send netmap status, as it's misleading when we're
// not logged in.
nm = nil
}
new := Status{
LoginFinished: loginFin,
LogoutFinished: logoutFin,
URL: url,
Persist: p,
NetMap: nm,
State: state,
Err: err,
URL: url,
Persist: p,
NetMap: nm,
Err: err,
state: state,
}
c.observer.SetControlClientStatus(new)
c.mu.Lock()
c.inSendStatus--
c.mu.Unlock()
// Launch a new goroutine to avoid blocking the caller while the observer
// does its thing, which may result in a call back into the client.
c.observerQueue.Add(func() {
c.observer.SetControlClientStatus(c, new)
})
}
func (c *Auto) Login(t *tailcfg.Oauth2Token, flags LoginFlags) {
c.logf("client.Login(%v, %v)", t != nil, flags)
c.mu.Lock()
c.loginGoal = &LoginGoal{
wantLoggedIn: true,
token: t,
flags: flags,
defer c.mu.Unlock()
if c.closed {
return
}
c.mu.Unlock()
c.cancelAuth()
c.wantLoggedIn = true
c.loginGoal = &LoginGoal{
token: t,
flags: flags,
}
c.cancelMapCtxLocked()
c.cancelAuthCtxLocked()
}
func (c *Auto) StartLogout() {
c.logf("client.StartLogout()")
c.mu.Lock()
c.loginGoal = &LoginGoal{
wantLoggedIn: false,
}
c.mu.Unlock()
c.cancelAuth()
}
var ErrClientClosed = errors.New("client closed")
func (c *Auto) Logout(ctx context.Context) error {
c.logf("client.Logout()")
errc := make(chan error, 1)
c.mu.Lock()
c.loginGoal = &LoginGoal{
wantLoggedIn: false,
loggedOutResult: errc,
}
c.wantLoggedIn = false
c.loginGoal = nil
closed := c.closed
c.mu.Unlock()
c.cancelAuth()
timer, timerChannel := c.clock.NewTimer(10 * time.Second)
defer timer.Stop()
select {
case err := <-errc:
return err
case <-ctx.Done():
return ctx.Err()
case <-timerChannel:
return context.DeadlineExceeded
if closed {
return ErrClientClosed
}
if err := c.direct.TryLogout(ctx); err != nil {
return err
}
c.mu.Lock()
c.loggedIn = false
c.state = StateNotAuthenticated
c.cancelAuthCtxLocked()
c.cancelMapCtxLocked()
c.mu.Unlock()
c.sendStatus("authRoutine-wantout", nil, "", nil)
return nil
}
func (c *Auto) SetExpirySooner(ctx context.Context, expiry time.Time) error {
@@ -745,26 +687,32 @@ func (c *Auto) Shutdown() {
c.logf("client.Shutdown()")
c.mu.Lock()
inSendStatus := c.inSendStatus
closed := c.closed
direct := c.direct
if !closed {
c.closed = true
c.observerQueue.shutdown()
c.cancelAuthCtxLocked()
c.cancelMapCtxLocked()
for _, w := range c.unpauseWaiters {
w <- false
}
c.unpauseWaiters = nil
}
c.mu.Unlock()
c.logf("client.Shutdown: inSendStatus=%v", inSendStatus)
c.logf("client.Shutdown")
if !closed {
c.unregisterHealthWatch()
close(c.quit)
c.cancelAuth()
<-c.authDone
c.cancelMap()
<-c.mapDone
<-c.updateDone
if direct != nil {
direct.Close()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c.observerQueue.wait(ctx)
c.logf("Client.Shutdown done.")
}
}
@@ -805,3 +753,95 @@ func (c *Auto) DoNoiseRequest(req *http.Request) (*http.Response, error) {
func (c *Auto) GetSingleUseNoiseRoundTripper(ctx context.Context) (http.RoundTripper, *tailcfg.EarlyNoise, error) {
return c.direct.GetSingleUseNoiseRoundTripper(ctx)
}
type execQueue struct {
mu sync.Mutex
closed bool
inFlight bool // whether a goroutine is running q.run
doneWaiter chan struct{} // non-nil if waiter is waiting, then closed
queue []func()
}
func (q *execQueue) Add(f func()) {
q.mu.Lock()
defer q.mu.Unlock()
if q.closed {
return
}
if q.inFlight {
q.queue = append(q.queue, f)
} else {
q.inFlight = true
go q.run(f)
}
}
// RunSync waits for the queue to be drained and then synchronously runs f.
// It returns an error if the queue is closed before f is run or ctx expires.
func (q *execQueue) RunSync(ctx context.Context, f func()) error {
for {
if err := q.wait(ctx); err != nil {
return err
}
q.mu.Lock()
if q.inFlight {
q.mu.Unlock()
continue
}
defer q.mu.Unlock()
if q.closed {
return errors.New("closed")
}
f()
return nil
}
}
func (q *execQueue) run(f func()) {
f()
q.mu.Lock()
for len(q.queue) > 0 && !q.closed {
f := q.queue[0]
q.queue[0] = nil
q.queue = q.queue[1:]
q.mu.Unlock()
f()
q.mu.Lock()
}
q.inFlight = false
q.queue = nil
if q.doneWaiter != nil {
close(q.doneWaiter)
q.doneWaiter = nil
}
q.mu.Unlock()
}
func (q *execQueue) shutdown() {
q.mu.Lock()
defer q.mu.Unlock()
q.closed = true
}
// wait waits for the queue to be empty.
func (q *execQueue) wait(ctx context.Context) error {
q.mu.Lock()
waitCh := q.doneWaiter
if q.inFlight && waitCh == nil {
waitCh = make(chan struct{})
q.doneWaiter = waitCh
}
q.mu.Unlock()
if waitCh == nil {
return nil
}
select {
case <-waitCh:
return nil
case <-ctx.Done():
return ctx.Err()
}
}

View File

@@ -25,6 +25,9 @@ const (
// Client represents a client connection to the control server.
// Currently this is done through a pair of polling https requests in
// the Auto client, but that might change eventually.
//
// The Client must be comparable as it is used by the Observer to detect stale
// clients.
type Client interface {
// Shutdown closes this session, which should not be used any further
// afterwards.
@@ -34,10 +37,6 @@ type Client interface {
// LoginFinished flag (on success) or an auth URL (if further
// interaction is needed).
Login(*tailcfg.Oauth2Token, LoginFlags)
// StartLogout starts an asynchronous logout process.
// When it finishes, the Status callback will be called while
// AuthCantContinue()==true.
StartLogout()
// Logout starts a synchronous logout process. It doesn't return
// until the logout operation has been completed.
Logout(context.Context) error

View File

@@ -6,8 +6,6 @@ package controlclient
import (
"reflect"
"testing"
"tailscale.com/types/empty"
)
func fieldsOf(t reflect.Type) (fields []string) {
@@ -21,7 +19,7 @@ func fieldsOf(t reflect.Type) (fields []string) {
func TestStatusEqual(t *testing.T) {
// Verify that the Equal method stays in sync with reality
equalHandles := []string{"LoginFinished", "LogoutFinished", "Err", "URL", "NetMap", "State", "Persist"}
equalHandles := []string{"Err", "URL", "NetMap", "Persist", "state"}
if have := fieldsOf(reflect.TypeOf(Status{})); !reflect.DeepEqual(have, equalHandles) {
t.Errorf("Status.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
have, equalHandles)
@@ -52,18 +50,8 @@ func TestStatusEqual(t *testing.T) {
true,
},
{
&Status{State: StateNew},
&Status{State: StateNew},
true,
},
{
&Status{State: StateNew},
&Status{State: StateAuthenticated},
false,
},
{
&Status{LoginFinished: nil},
&Status{LoginFinished: new(empty.Message)},
&Status{},
&Status{state: StateAuthenticated},
false,
},
}

View File

@@ -22,9 +22,9 @@ import (
"os"
"reflect"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"go4.org/mem"
@@ -42,14 +42,13 @@ import (
"tailscale.com/net/tlsdial"
"tailscale.com/net/tsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/syncs"
"tailscale.com/smallzstd"
"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"
@@ -64,11 +63,10 @@ type Direct struct {
httpc *http.Client // HTTP client used to talk to tailcontrol
dialer *tsdial.Dialer
dnsCache *dnscache.Resolver
serverURL string // URL of the tailcontrol server
controlKnobs *controlknobs.Knobs // always non-nil
serverURL string // URL of the tailcontrol server
clock tstime.Clock
lastPrintMap time.Time
newDecompressor func() (Decompressor, error)
keepAlive bool
logf logger.Logf
netMon *netmon.Monitor // or nil
discoPubKey key.DiscoPublic
@@ -104,7 +102,11 @@ type Direct struct {
// Observer is implemented by users of the control client (such as LocalBackend)
// to get notified of changes in the control client's status.
type Observer interface {
SetControlClientStatus(Status)
// SetControlClientStatus is called when the client has a new status to
// report. The Client is provided to allow the Observer to track which
// Client is reporting the status, allowing it to ignore stale status
// reports from previous Clients.
SetControlClientStatus(Client, Status)
}
type Options struct {
@@ -115,8 +117,6 @@ type Options struct {
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
Logf logger.Logf
HTTPTestClient *http.Client // optional HTTP client to use (for tests only)
NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only)
@@ -127,6 +127,7 @@ type Options struct {
OnControlTime func(time.Time) // optional func to notify callers of new time from control
Dialer *tsdial.Dialer // non-nil
C2NHandler http.Handler // or nil
ControlKnobs *controlknobs.Knobs // or nil to ignore
// Observer is called when there's a change in status to report
// from the control client.
@@ -192,6 +193,19 @@ type NetmapUpdater interface {
// the diff themselves between the previous full & next full network maps.
}
// NetmapDeltaUpdater is an optional interface that can be implemented by
// NetmapUpdater implementations to receive delta updates from the controlclient
// rather than just full updates.
type NetmapDeltaUpdater interface {
// UpdateNetmapDelta is called with discrete changes to the network map.
//
// The ok result is whether the implementation was able to apply the
// mutations. It might return false if its internal state doesn't
// support applying them or a NetmapUpdater it's wrapping doesn't
// implement the NetmapDeltaUpdater optional method.
UpdateNetmapDelta([]netmap.NodeMutation) (ok bool)
}
// NewDirect returns a new Direct client.
func NewDirect(opts Options) (*Direct, error) {
if opts.ServerURL == "" {
@@ -200,6 +214,9 @@ func NewDirect(opts Options) (*Direct, error) {
if opts.GetMachinePrivateKey == nil {
return nil, errors.New("controlclient.New: no GetMachinePrivateKey specified")
}
if opts.ControlKnobs == nil {
opts.ControlKnobs = &controlknobs.Knobs{}
}
opts.ServerURL = strings.TrimRight(opts.ServerURL, "/")
serverURL, err := url.Parse(opts.ServerURL)
if err != nil {
@@ -247,12 +264,11 @@ func NewDirect(opts Options) (*Direct, error) {
c := &Direct{
httpc: httpc,
controlKnobs: opts.ControlKnobs,
getMachinePrivKey: opts.GetMachinePrivateKey,
serverURL: opts.ServerURL,
clock: opts.Clock,
logf: opts.Logf,
newDecompressor: opts.NewDecompressor,
keepAlive: opts.KeepAlive,
persist: opts.Persist.View(),
authKey: opts.AuthKey,
discoPubKey: opts.DiscoPublicKey,
@@ -736,18 +752,6 @@ func resignNKS(priv key.NLPrivate, nodeKey key.NodePublic, oldNKS tkatype.Marsha
return newSig.Serialize(), nil
}
func sameEndpoints(a, b []tailcfg.Endpoint) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// newEndpoints acquires c.mu and sets the local port and endpoints and reports
// whether they've changed.
//
@@ -757,15 +761,11 @@ func (c *Direct) newEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
defer c.mu.Unlock()
// Nothing new?
if sameEndpoints(c.endpoints, endpoints) {
if slices.Equal(c.endpoints, endpoints) {
return false // unchanged
}
var epStrs []string
for _, ep := range endpoints {
epStrs = append(epStrs, ep.Addr.String())
}
c.logf("[v2] client.newEndpoints(%v)", epStrs)
c.endpoints = append(c.endpoints[:0], endpoints...)
c.logf("[v2] client.newEndpoints(%v)", endpoints)
c.endpoints = slices.Clone(endpoints)
return true // changed
}
@@ -878,7 +878,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
request := &tailcfg.MapRequest{
Version: tailcfg.CurrentCapabilityVersion,
KeepAlive: c.keepAlive,
KeepAlive: true,
NodeKey: persist.PublicNodeKey(),
DiscoKey: c.discoPubKey,
Endpoints: epStrs,
@@ -905,9 +905,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
old := request.DebugFlags
request.DebugFlags = append(old[:len(old):len(old)], extraDebugFlags...)
}
if c.newDecompressor != nil {
request.Compress = "zstd"
}
request.Compress = "zstd"
bodyData, err := encode(request, serverKey, serverNoiseKey, machinePrivKey)
if err != nil {
@@ -964,7 +962,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
var mapResIdx int // 0 for first message, then 1+ for deltas
sess := newMapSession(persist.PrivateNodeKey(), nu)
sess := newMapSession(persist.PrivateNodeKey(), nu, c.controlKnobs)
defer sess.Close()
sess.cancel = cancel
sess.logf = c.logf
@@ -1191,19 +1189,14 @@ func (c *Direct) decodeMsg(msg []byte, v any, mkey key.MachinePrivate) error {
} else {
decrypted = msg
}
var b []byte
if c.newDecompressor == nil {
b = decrypted
} else {
decoder, err := c.newDecompressor()
if err != nil {
return err
}
defer decoder.Close()
b, err = decoder.DecodeAll(decrypted, nil)
if err != nil {
return err
}
decoder, err := smallzstd.NewDecoder(nil)
if err != nil {
return err
}
defer decoder.Close()
b, err := decoder.DecodeAll(decrypted, nil)
if err != nil {
return err
}
if debugMap() {
var buf bytes.Buffer
@@ -1310,68 +1303,6 @@ func initDevKnob() devKnobs {
var clock tstime.Clock = tstime.StdClock{}
// config from control.
var (
controlDisableDRPO atomic.Bool
controlKeepFullWGConfig atomic.Bool
controlRandomizeClientPort atomic.Bool
controlOneCGNAT syncs.AtomicValue[opt.Bool]
)
// DisableDRPO reports whether control says to disable the
// DERP route optimization (Issue 150).
func DisableDRPO() bool {
return controlDisableDRPO.Load()
}
// KeepFullWGConfig reports whether control says we should disable the lazy
// wireguard programming and instead give it the full netmap always.
func KeepFullWGConfig() bool {
return controlKeepFullWGConfig.Load()
}
// RandomizeClientPort reports whether control says we should randomize
// the client port.
func RandomizeClientPort() bool {
return controlRandomizeClientPort.Load()
}
// ControlOneCGNATSetting returns control's OneCGNAT setting, if any.
func ControlOneCGNATSetting() opt.Bool {
return controlOneCGNAT.Load()
}
func setControlKnobsFromNodeAttrs(selfNodeAttrs []string) {
var (
keepFullWG bool
disableDRPO bool
disableUPnP bool
randomizeClientPort bool
oneCGNAT opt.Bool
)
for _, attr := range selfNodeAttrs {
switch attr {
case tailcfg.NodeAttrDebugDisableWGTrim:
keepFullWG = true
case tailcfg.NodeAttrDebugDisableDRPO:
disableDRPO = true
case tailcfg.NodeAttrDisableUPnP:
disableUPnP = true
case tailcfg.NodeAttrRandomizeClientPort:
randomizeClientPort = true
case tailcfg.NodeAttrOneCGNATEnable:
oneCGNAT.Set(true)
case tailcfg.NodeAttrOneCGNATDisable:
oneCGNAT.Set(false)
}
}
controlKeepFullWGConfig.Store(keepFullWG)
controlDisableDRPO.Store(disableDRPO)
controlknobs.SetDisableUPnP(disableUPnP)
controlRandomizeClientPort.Store(randomizeClientPort)
controlOneCGNAT.Store(oneCGNAT)
}
// ipForwardingBroken reports whether the system's IP forwarding is disabled
// and will definitely not work for the routes provided.
//

View File

@@ -14,7 +14,9 @@ import (
"sort"
"strconv"
"sync"
"time"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
@@ -38,7 +40,8 @@ import (
// one MapRequest).
type mapSession struct {
// Immutable fields.
nu NetmapUpdater // called on changes (in addition to the optional hooks below)
netmapUpdater NetmapUpdater // called on changes (in addition to the optional hooks below)
controlKnobs *controlknobs.Knobs // or nil
privateNodeKey key.NodePrivate
publicNodeKey key.NodePublic
logf logger.Logf
@@ -94,9 +97,10 @@ type mapSession struct {
// Modify its optional fields on the returned value before use.
//
// It must have its Close method called to release resources.
func newMapSession(privateNodeKey key.NodePrivate, nu NetmapUpdater) *mapSession {
func newMapSession(privateNodeKey key.NodePrivate, nu NetmapUpdater, controlKnobs *controlknobs.Knobs) *mapSession {
ms := &mapSession{
nu: nu,
netmapUpdater: nu,
controlKnobs: controlKnobs,
privateNodeKey: privateNodeKey,
publicNodeKey: privateNodeKey.Public(),
lastDNSConfig: new(tailcfg.DNSConfig),
@@ -184,7 +188,7 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
if DevKnob.StripCaps() {
resp.Node.Capabilities = nil
}
setControlKnobsFromNodeAttrs(resp.Node.Capabilities)
ms.controlKnobs.UpdateFromNodeAttributes(resp.Node.Capabilities)
}
// Call Node.InitDisplayNames on any changed nodes.
@@ -194,8 +198,16 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
ms.updateStateFromResponse(resp)
nm := ms.netmap()
if ms.tryHandleIncrementally(resp) {
ms.onConciseNetMapSummary(ms.lastNetmapSummary) // every 5s log
return nil
}
// We have to rebuild the whole netmap (lots of garbage & work downstream of
// our UpdateFullNetmap call). This is the part we tried to avoid but
// some field mutations (especially rare ones) aren't yet handled.
nm := ms.netmap()
ms.lastNetmapSummary = nm.VeryConcise()
ms.onConciseNetMapSummary(ms.lastNetmapSummary)
@@ -204,10 +216,25 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
ms.onSelfNodeChanged(nm)
}
ms.nu.UpdateFullNetmap(nm)
ms.netmapUpdater.UpdateFullNetmap(nm)
return nil
}
func (ms *mapSession) tryHandleIncrementally(res *tailcfg.MapResponse) bool {
if ms.controlKnobs != nil && ms.controlKnobs.DisableDeltaUpdates.Load() {
return false
}
nud, ok := ms.netmapUpdater.(NetmapDeltaUpdater)
if !ok {
return false
}
mutations, ok := netmap.MutationsFromMapResponse(res, time.Now())
if ok && len(mutations) > 0 {
return nud.UpdateNetmapDelta(mutations)
}
return ok
}
// updateStats are some stats from updateStateFromResponse, primarily for
// testing. It's meant to be cheap enough to always compute, though. It doesn't
// allocate.

View File

@@ -16,6 +16,7 @@ import (
"github.com/google/go-cmp/cmp"
"go4.org/mem"
"tailscale.com/control/controlknobs"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstime"
@@ -392,7 +393,7 @@ func formatNodes(nodes []*tailcfg.Node) string {
}
func newTestMapSession(t testing.TB, nu NetmapUpdater) *mapSession {
ms := newMapSession(key.NewNode(), nu)
ms := newMapSession(key.NewNode(), nu, new(controlknobs.Knobs))
t.Cleanup(ms.Close)
ms.logf = t.Logf
return ms

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"reflect"
"tailscale.com/types/empty"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/structs"
@@ -38,6 +37,10 @@ const (
StateSynchronized // connected and received map update
)
func (s State) AppendText(b []byte) ([]byte, error) {
return append(b, s.String()...), nil
}
func (s State) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
@@ -62,34 +65,55 @@ func (s State) String() string {
}
type Status struct {
_ structs.Incomparable
LoginFinished *empty.Message // nonempty when login finishes
LogoutFinished *empty.Message // nonempty when logout finishes
Err error
URL string // interactive URL to visit to finish logging in
NetMap *netmap.NetworkMap // server-pushed configuration
_ structs.Incomparable
// The internal state should not be exposed outside this
// Err, if non-nil, is an error that occurred while logging in.
//
// If it's of type UserVisibleError then it's meant to be shown to users in
// their Tailscale client. Otherwise it's just logged to tailscaled's logs.
Err error
// URL, if non-empty, is the interactive URL to visit to finish logging in.
URL string
// NetMap is the latest server-pushed state of the tailnet network.
NetMap *netmap.NetworkMap
// Persist, when Valid, is the locally persisted configuration.
//
// TODO(bradfitz,maisem): clarify this.
Persist persist.PersistView
// state is the internal state. It should not be exposed outside this
// package, but we have some automated tests elsewhere that need to
// use them. Please don't use these fields.
// use it via the StateForTest accessor.
// TODO(apenwarr): Unexport or remove these.
State State
Persist *persist.PersistView // locally persisted configuration
state State
}
// LoginFinished reports whether the controlclient is in its "StateAuthenticated"
// state where it's in a happy register state but not yet in a map poll.
//
// TODO(bradfitz): delete this and everything around Status.state.
func (s *Status) LoginFinished() bool { return s.state == StateAuthenticated }
// StateForTest returns the internal state of s for tests only.
func (s *Status) StateForTest() State { return s.state }
// SetStateForTest sets the internal state of s for tests only.
func (s *Status) SetStateForTest(state State) { s.state = state }
// Equal reports whether s and s2 are equal.
func (s *Status) Equal(s2 *Status) bool {
if s == nil && s2 == nil {
return true
}
return s != nil && s2 != nil &&
(s.LoginFinished == nil) == (s2.LoginFinished == nil) &&
(s.LogoutFinished == nil) == (s2.LogoutFinished == nil) &&
s.Err == s2.Err &&
s.URL == s2.URL &&
s.state == s2.state &&
reflect.DeepEqual(s.Persist, s2.Persist) &&
reflect.DeepEqual(s.NetMap, s2.NetMap) &&
s.State == s2.State
reflect.DeepEqual(s.NetMap, s2.NetMap)
}
func (s Status) String() string {
@@ -97,5 +121,5 @@ func (s Status) String() string {
if err != nil {
panic(err)
}
return s.State.String() + " " + string(b)
return s.state.String() + " " + string(b)
}

View File

@@ -51,7 +51,7 @@ func (d *Dialer) Dial(ctx context.Context) (*ClientConn, error) {
if err != nil {
return nil, err
}
netConn := wsconn.NetConn(context.Background(), wsConn, websocket.MessageBinary)
netConn := wsconn.NetConn(context.Background(), wsConn, websocket.MessageBinary, wsURL.String())
cbConn, err := cont(ctx, netConn)
if err != nil {
netConn.Close()

View File

@@ -146,7 +146,7 @@ func acceptWebsocket(ctx context.Context, w http.ResponseWriter, r *http.Request
return nil, fmt.Errorf("decoding base64 handshake parameter: %v", err)
}
conn := wsconn.NetConn(ctx, c, websocket.MessageBinary)
conn := wsconn.NetConn(ctx, c, websocket.MessageBinary, r.RemoteAddr)
nc, err := controlbase.Server(ctx, conn, private, init)
if err != nil {
conn.Close()

View File

@@ -8,22 +8,101 @@ package controlknobs
import (
"sync/atomic"
"tailscale.com/envknob"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
)
// disableUPnP indicates whether to attempt UPnP mapping.
var disableUPnPControl atomic.Bool
// Knobs is the set of knobs that the control plane's coordination server can
// adjust at runtime.
type Knobs struct {
// DisableUPnP indicates whether to attempt UPnP mapping.
DisableUPnP atomic.Bool
var disableUPnpEnv = envknob.RegisterBool("TS_DISABLE_UPNP")
// DisableDRPO is whether control says to disable the
// DERP route optimization (Issue 150).
DisableDRPO atomic.Bool
// DisableUPnP reports the last reported value from control
// whether UPnP portmapping should be disabled.
func DisableUPnP() bool {
return disableUPnPControl.Load() || disableUPnpEnv()
// KeepFullWGConfig is whether we should disable the lazy wireguard
// programming and instead give WireGuard the full netmap always, even for
// idle peers.
KeepFullWGConfig atomic.Bool
// RandomizeClientPort is whether control says we should randomize
// the client port.
RandomizeClientPort atomic.Bool
// OneCGNAT is whether the the node should make one big CGNAT route
// in the OS rather than one /32 per peer.
OneCGNAT syncs.AtomicValue[opt.Bool]
// ForceBackgroundSTUN forces netcheck STUN queries to keep
// running in magicsock, even when idle.
ForceBackgroundSTUN atomic.Bool
// DisableDeltaUpdates is whether the node should not process
// incremental (delta) netmap updates and should treat all netmap
// changes as "full" ones as tailscaled did in 1.48.x and earlier.
DisableDeltaUpdates atomic.Bool
}
// SetDisableUPnP sets whether control says that UPnP should be
// disabled.
func SetDisableUPnP(v bool) {
disableUPnPControl.Store(v)
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
// node attributes (Node.Capabilities).
func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []string) {
if k == nil {
return
}
var (
keepFullWG bool
disableDRPO bool
disableUPnP bool
randomizeClientPort bool
disableDeltaUpdates bool
oneCGNAT opt.Bool
forceBackgroundSTUN bool
)
for _, attr := range selfNodeAttrs {
switch attr {
case tailcfg.NodeAttrDebugDisableWGTrim:
keepFullWG = true
case tailcfg.NodeAttrDebugDisableDRPO:
disableDRPO = true
case tailcfg.NodeAttrDisableUPnP:
disableUPnP = true
case tailcfg.NodeAttrRandomizeClientPort:
randomizeClientPort = true
case tailcfg.NodeAttrOneCGNATEnable:
oneCGNAT.Set(true)
case tailcfg.NodeAttrOneCGNATDisable:
oneCGNAT.Set(false)
case tailcfg.NodeAttrDebugForceBackgroundSTUN:
forceBackgroundSTUN = true
case tailcfg.NodeAttrDisableDeltaUpdates:
disableDeltaUpdates = true
}
}
k.KeepFullWGConfig.Store(keepFullWG)
k.DisableDRPO.Store(disableDRPO)
k.DisableUPnP.Store(disableUPnP)
k.RandomizeClientPort.Store(randomizeClientPort)
k.OneCGNAT.Store(oneCGNAT)
k.ForceBackgroundSTUN.Store(forceBackgroundSTUN)
k.DisableDeltaUpdates.Store(disableDeltaUpdates)
}
// AsDebugJSON returns k as something that can be marshalled with json.Marshal
// for debug.
func (k *Knobs) AsDebugJSON() map[string]any {
if k == nil {
return nil
}
return map[string]any{
"DisableUPnP": k.DisableUPnP.Load(),
"DisableDRPO": k.DisableDRPO.Load(),
"KeepFullWGConfig": k.KeepFullWGConfig.Load(),
"RandomizeClientPort": k.RandomizeClientPort.Load(),
"OneCGNAT": k.OneCGNAT.Load(),
"ForceBackgroundSTUN": k.ForceBackgroundSTUN.Load(),
"DisableDeltaUpdates": k.DisableDeltaUpdates.Load(),
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package controlknobs
import (
"reflect"
"testing"
)
func TestAsDebugJSON(t *testing.T) {
var nilPtr *Knobs
if got := nilPtr.AsDebugJSON(); got != nil {
t.Errorf("AsDebugJSON(nil) = %v; want nil", got)
}
k := new(Knobs)
got := k.AsDebugJSON()
if want := reflect.TypeOf(Knobs{}).NumField(); len(got) != want {
t.Errorf("AsDebugJSON map has %d fields; want %v", len(got), want)
}
}

View File

@@ -730,8 +730,9 @@ func firstStr(a, b string) string {
}
// dialNodeUsingProxy connects to n using a CONNECT to the HTTP(s) proxy in proxyURL.
func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, proxyURL *url.URL) (proxyConn net.Conn, err error) {
func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, proxyURL *url.URL) (_ net.Conn, err error) {
pu := proxyURL
var proxyConn net.Conn
if pu.Scheme == "https" {
var d tls.Dialer
proxyConn, err = d.DialContext(ctx, "tcp", net.JoinHostPort(pu.Hostname(), firstStr(pu.Port(), "443")))

View File

@@ -27,6 +27,6 @@ func dialWebsocket(ctx context.Context, urlStr string) (net.Conn, error) {
return nil, err
}
log.Printf("websocket: connected to %v", urlStr)
netConn := wsconn.NetConn(context.Background(), c, websocket.MessageBinary)
netConn := wsconn.NetConn(context.Background(), c, websocket.MessageBinary, urlStr)
return netConn, nil
}

View File

@@ -389,12 +389,24 @@ func CanTaildrop() bool { return !Bool("TS_DISABLE_TAILDROP") }
// SSHPolicyFile returns the path, if any, to the SSHPolicy JSON file for development.
func SSHPolicyFile() string { return String("TS_DEBUG_SSH_POLICY_FILE") }
// SSHIgnoreTailnetPolicy is whether to ignore the Tailnet SSH policy for development.
// SSHIgnoreTailnetPolicy reports whether to ignore the Tailnet SSH policy for development.
func SSHIgnoreTailnetPolicy() bool { return Bool("TS_DEBUG_SSH_IGNORE_TAILNET_POLICY") }
// TKASkipSignatureCheck is whether to skip node-key signature checking for development.
// TKASkipSignatureCheck reports whether to skip node-key signature checking for development.
func TKASkipSignatureCheck() bool { return Bool("TS_UNSAFE_SKIP_NKS_VERIFICATION") }
// CrashOnUnexpected reports whether the Tailscale client should panic
// on unexpected conditions. If TS_DEBUG_CRASH_ON_UNEXPECTED is set, that's
// used. Otherwise the default value is true for unstable builds.
func CrashOnUnexpected() bool {
if v, ok := crashOnUnexpected().Get(); ok {
return v
}
return version.IsUnstableBuild()
}
var crashOnUnexpected = RegisterOptBool("TS_DEBUG_CRASH_ON_UNEXPECTED")
// NoLogsNoSupport reports whether the client's opted out of log uploads and
// technical support.
func NoLogsNoSupport() bool {

View File

@@ -115,4 +115,4 @@
in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
}
# nix-direnv cache busting line: sha256-GACTVegfg57CKUMczmGuovr+zqcGzzpT6cEw+QLPvSs=
# nix-direnv cache busting line: sha256-TZP/FQqb21yiKMlIPXXSoN6HfiBAun+gPZHQ5cPc8L0=

3
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssm v1.36.3
github.com/coreos/go-iptables v0.6.0
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/coreos/go-systemd/v22 v22.4.0
github.com/creack/pty v1.1.18
github.com/dave/jennifer v1.6.1
github.com/dblohm7/wingoes v0.0.0-20230821191801-fc76608aecf0
@@ -26,7 +27,7 @@ require (
github.com/go-json-experiment/json v0.0.0-20230321051131-ccbac49a6929
github.com/go-logr/zapr v1.2.4
github.com/go-ole/go-ole v1.2.6
github.com/godbus/dbus/v5 v5.1.0
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
github.com/golangci/golangci-lint v1.52.2
github.com/google/go-cmp v0.5.9

View File

@@ -1 +1 @@
sha256-GACTVegfg57CKUMczmGuovr+zqcGzzpT6cEw+QLPvSs=
sha256-TZP/FQqb21yiKMlIPXXSoN6HfiBAun+gPZHQ5cPc8L0=

8
go.sum
View File

@@ -208,6 +208,8 @@ github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo
github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd/v22 v22.4.0 h1:y9YHcjnjynCd/DVbg5j9L/33jQM3MxJlbj/zWskzfGU=
github.com/coreos/go-systemd/v22 v22.4.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -356,8 +358,9 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -1178,6 +1181,7 @@ golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -27,7 +27,7 @@ var (
sysErr = map[Subsystem]error{} // error key => err (or nil for no error)
watchers = set.HandleSet[func(Subsystem, error)]{} // opt func to run if error state changes
warnables = map[*Warnable]struct{}{} // set of warnables
warnables = set.Set[*Warnable]{}
timer *time.Timer
debugHandler = map[string]http.Handler{}
@@ -84,7 +84,7 @@ func NewWarnable(opts ...WarnableOpt) *Warnable {
}
mu.Lock()
defer mu.Unlock()
warnables[w] = struct{}{}
warnables.Add(w)
return w
}
@@ -279,27 +279,31 @@ func SetControlHealth(problems []string) {
// GotStreamedMapResponse notes that we got a tailcfg.MapResponse
// message in streaming mode, even if it's just a keep-alive message.
//
// This also notes that a map poll is in progress. To unset that, call
// SetOutOfPollNetMap().
func GotStreamedMapResponse() {
mu.Lock()
defer mu.Unlock()
lastStreamedMapResponse = time.Now()
if !inMapPoll {
inMapPoll = true
inMapPollSince = time.Now()
}
selfCheckLocked()
}
// SetInPollNetMap records whether the client has an open
// HTTP long poll open to the control plane.
func SetInPollNetMap(v bool) {
// SetOutOfPollNetMap records that the client is no longer in
// an HTTP map request long poll to the control plane.
func SetOutOfPollNetMap() {
mu.Lock()
defer mu.Unlock()
if v == inMapPoll {
if !inMapPoll {
return
}
inMapPoll = v
if v {
inMapPollSince = time.Now()
} else {
lastMapPollEndedAt = time.Now()
}
inMapPoll = false
lastMapPollEndedAt = time.Now()
selfCheckLocked()
}
// GetInPollNetMap reports whether the client has an open

View File

@@ -8,6 +8,8 @@ import (
"fmt"
"reflect"
"testing"
"tailscale.com/util/set"
)
func TestAppendWarnableDebugFlags(t *testing.T) {
@@ -35,5 +37,5 @@ func TestAppendWarnableDebugFlags(t *testing.T) {
func resetWarnables() {
mu.Lock()
defer mu.Unlock()
warnables = make(map[*Warnable]struct{})
warnables = set.Set[*Warnable]{}
}

View File

@@ -61,7 +61,7 @@ const (
// each one via RequestEngineStatus.
NotifyWatchEngineUpdates NotifyWatchOpt = 1 << iota
NotifyInitialState // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL
NotifyInitialState // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL + SessionID
NotifyInitialPrefs // if set, the first Notify message (sent immediately) will contain the current Prefs
NotifyInitialNetMap // if set, the first Notify message (sent immediately) will contain the current NetMap
@@ -77,6 +77,12 @@ type Notify struct {
_ structs.Incomparable
Version string // version number of IPN backend
// SessionID identifies the unique WatchIPNBus session.
// This field is only set in the first message when requesting
// NotifyInitialState. Clients must store it on their side as
// following notifications will not include this field.
SessionID string `json:",omitempty"`
// ErrMessage, if non-nil, contains a critical error message.
// For State InUseOtherUser, ErrMessage is not critical and just contains the details.
ErrMessage *string

View File

@@ -51,6 +51,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
NetfilterMode preftype.NetfilterMode
OperatorUser string
ProfileName string
AutoUpdate AutoUpdatePrefs
Persist *persist.Persist
}{})
@@ -75,6 +76,12 @@ func (src *ServeConfig) Clone() *ServeConfig {
}
}
dst.AllowFunnel = maps.Clone(src.AllowFunnel)
if dst.Foreground != nil {
dst.Foreground = map[string]*ServeConfig{}
for k, v := range src.Foreground {
dst.Foreground[k] = v.Clone()
}
}
return dst
}
@@ -83,6 +90,7 @@ var _ServeConfigCloneNeedsRegeneration = ServeConfig(struct {
TCP map[uint16]*TCPPortHandler
Web map[HostPort]*WebServerConfig
AllowFunnel map[HostPort]bool
Foreground map[string]*ServeConfig
}{})
// Clone makes a deep copy of TCPPortHandler.

View File

@@ -86,6 +86,7 @@ func (v PrefsView) NoSNAT() bool { return v.ж.NoSNAT }
func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.NetfilterMode }
func (v PrefsView) OperatorUser() string { return v.ж.OperatorUser }
func (v PrefsView) ProfileName() string { return v.ж.ProfileName }
func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpdate }
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
@@ -111,6 +112,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
NetfilterMode preftype.NetfilterMode
OperatorUser string
ProfileName string
AutoUpdate AutoUpdatePrefs
Persist *persist.Persist
}{})
@@ -175,11 +177,18 @@ func (v ServeConfigView) AllowFunnel() views.Map[HostPort, bool] {
return views.MapOf(v.ж.AllowFunnel)
}
func (v ServeConfigView) Foreground() views.MapFn[string, *ServeConfig, ServeConfigView] {
return views.MapFnOf(v.ж.Foreground, func(t *ServeConfig) ServeConfigView {
return t.View()
})
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ServeConfigViewNeedsRegeneration = ServeConfig(struct {
TCP map[uint16]*TCPPortHandler
Web map[HostPort]*WebServerConfig
AllowFunnel map[HostPort]bool
Foreground map[string]*ServeConfig
}{})
// View returns a readonly view of TCPPortHandler.

View File

@@ -4,6 +4,7 @@
package ipnlocal
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -14,15 +15,16 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/net/sockstats"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
"tailscale.com/util/goroutines"
"tailscale.com/version"
"tailscale.com/version/distro"
)
var c2nLogHeap func(http.ResponseWriter, *http.Request) // non-nil on most platforms (c2n_pprof.go)
@@ -38,7 +40,15 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.Write(body)
case "/update":
b.handleC2NUpdate(w, r)
switch r.Method {
case http.MethodGet:
b.handleC2NUpdateGet(w, r)
case http.MethodPost:
b.handleC2NUpdatePost(w, r)
default:
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
case "/logtail/flush":
if r.Method != "POST" {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
@@ -111,39 +121,48 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
}
}
func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) {
// TODO(bradfitz): add some sort of semaphore that prevents two concurrent
// updates, or if one happened in the past 5 minutes, or something.
func (b *LocalBackend) handleC2NUpdateGet(w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received")
var res tailcfg.C2NUpdateResponse
res.Enabled = envknob.AllowsRemoteUpdate()
res.Supported = runtime.GOOS == "windows" || (runtime.GOOS == "linux" && distro.Get() == distro.Debian)
res := b.newC2NUpdateResponse()
res.Started = b.c2nUpdateStarted()
switch r.Method {
case "GET", "POST":
default:
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func (b *LocalBackend) handleC2NUpdatePost(w http.ResponseWriter, r *http.Request) {
b.logf("c2n: POST /update received")
res := b.newC2NUpdateResponse()
defer func() {
if res.Err != "" {
b.logf("c2n: POST /update failed: %s", res.Err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}()
if r.Method == "GET" {
return
}
if !res.Enabled {
res.Err = "not enabled"
return
}
if !res.Supported {
res.Err = "not supported"
return
}
// Check if update was already started, and mark as started.
if !b.trySetC2NUpdateStarted() {
res.Err = "update already started"
return
}
defer func() {
// Clear the started flag if something failed.
if res.Err != "" {
b.setC2NUpdateStarted(false)
}
}()
cmdTS, err := findCmdTailscale()
if err != nil {
res.Err = fmt.Sprintf("failed to find cmd/tailscale binary: %v", err)
@@ -165,22 +184,64 @@ func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) {
res.Err = "cmd/tailscale version mismatch"
return
}
cmd := exec.Command(cmdTS, "update", "--yes")
buf := new(bytes.Buffer)
cmd.Stdout = buf
cmd.Stderr = buf
b.logf("c2n: running %q", strings.Join(cmd.Args, " "))
if err := cmd.Start(); err != nil {
res.Err = fmt.Sprintf("failed to start cmd/tailscale update: %v", err)
return
}
res.Started = true
// TODO(bradfitz,andrew): There might be a race condition here on Windows:
// * We start the update process.
// * tailscale.exe copies itself and kicks off the update process
// * msiexec stops this process during the update before the selfCopy exits(?)
// * This doesn't return because the process is dead.
// Run update asynchronously and respond that it started.
go func() {
if err := cmd.Wait(); err != nil {
b.logf("c2n: update command failed: %v, output: %s", err, buf)
} else {
b.logf("c2n: update complete")
}
b.setC2NUpdateStarted(false)
}()
}
func (b *LocalBackend) newC2NUpdateResponse() tailcfg.C2NUpdateResponse {
// If NewUpdater does not return an error, we can update the installation.
// Exception: When version.IsMacSysExt returns true, we don't support that
// yet. TODO(cpalmer, #6995): Implement it.
//
// This seems fairly unlikely, but worth checking.
defer cmd.Wait()
return
// Note that we create the Updater solely to check for errors; we do not
// invoke it here. For this purpose, it is ok to pass it a zero Arguments.
prefs := b.Prefs().AutoUpdate()
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
return tailcfg.C2NUpdateResponse{
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply,
Supported: err == nil && !version.IsMacSysExt(),
}
}
func (b *LocalBackend) c2nUpdateStarted() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.c2nUpdateStatus.started
}
func (b *LocalBackend) setC2NUpdateStarted(v bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.c2nUpdateStatus.started = v
}
func (b *LocalBackend) trySetC2NUpdateStarted() bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.c2nUpdateStatus.started {
return false
}
b.c2nUpdateStatus.started = true
return true
}
// findCmdTailscale looks for the cmd/tailscale that corresponds to the

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"log"
"maps"
"net"
"net/http"
"net/http/httputil"
@@ -33,6 +34,7 @@ import (
"gvisor.dev/gvisor/pkg/tcpip"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/control/controlclient"
"tailscale.com/control/controlknobs"
"tailscale.com/doctor"
"tailscale.com/doctor/permissions"
"tailscale.com/doctor/routetable"
@@ -78,6 +80,7 @@ import (
"tailscale.com/util/mak"
"tailscale.com/util/multierr"
"tailscale.com/util/osshare"
"tailscale.com/util/rands"
"tailscale.com/util/set"
"tailscale.com/util/systemd"
"tailscale.com/util/testenv"
@@ -143,19 +146,17 @@ type LocalBackend struct {
statsLogf logger.Logf // for printing peers stats on change
sys *tsd.System
e wgengine.Engine // non-nil; TODO(bradfitz): remove; use sys
pm *profileManager
store ipn.StateStore // non-nil; TODO(bradfitz): remove; use sys
dialer *tsdial.Dialer // non-nil; TODO(bradfitz): remove; use sys
store ipn.StateStore // non-nil; TODO(bradfitz): remove; use sys
dialer *tsdial.Dialer // non-nil; TODO(bradfitz): remove; use sys
backendLogID logid.PublicID
unregisterNetMon func()
unregisterHealthWatch func()
portpoll *portlist.Poller // may be nil
portpollOnce sync.Once // guards starting readPoller
gotPortPollRes chan struct{} // closed upon first readPoller result
newDecompressor func() (controlclient.Decompressor, error)
varRoot string // or empty if SetVarRoot never called
logFlushFunc func() // or nil if SetLogFlusher wasn't called
em *expiryManager // non-nil
varRoot string // or empty if SetVarRoot never called
logFlushFunc func() // or nil if SetLogFlusher wasn't called
em *expiryManager // non-nil
sshAtomicBool atomic.Bool
shutdownCalled bool // if Shutdown has been called
debugSink *capture.Sink
@@ -188,6 +189,7 @@ type LocalBackend struct {
// The mutex protects the following elements.
mu sync.Mutex
pm *profileManager // mu guards access
filterHash deephash.Sum
httpTestClient *http.Client // for controlclient. nil by default, used by tests.
ccGen clientGen // function for producing controlclient; lazily populated
@@ -201,9 +203,8 @@ type LocalBackend struct {
capFileSharing bool // whether netMap contains the file sharing capability
capTailnetLock bool // whether netMap contains the tailnet lock capability
// hostinfo is mutated in-place while mu is held.
hostinfo *tailcfg.Hostinfo
// netMap is not mutated in-place once set.
netMap *netmap.NetworkMap
hostinfo *tailcfg.Hostinfo
netMap *netmap.NetworkMap // not mutated in place once set (except for Peers slice)
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil
nodeByAddr map[netip.Addr]tailcfg.NodeView
activeLogin string // last logged LoginName from netMap
@@ -238,16 +239,16 @@ type LocalBackend struct {
directFileRoot string
directFileDoFinalRename bool // false on macOS, true on several NAS platforms
componentLogUntil map[string]componentLogState
// c2nUpdateStatus is the status of c2n-triggered client update.
c2nUpdateStatus updateStatus
// ServeConfig fields. (also guarded by mu)
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
serveConfig ipn.ServeConfigView // or !Valid if none
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
serveConfig ipn.ServeConfigView // or !Valid if none
activeWatchSessions set.Set[string] // of WatchIPN SessionID
serveListeners map[netip.AddrPort]*serveListener // addrPort => serveListener
serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *httputil.ReverseProxy
// serveStreamers is a map for those running Funnel in the foreground
// and streaming incoming requests.
serveStreamers map[uint16]map[uint32]func(ipn.FunnelRequestLog) // serve port => map of stream loggers (key is UUID)
// statusLock must be held before calling statusChanged.Wait() or
// statusChanged.Broadcast().
@@ -266,6 +267,13 @@ type LocalBackend struct {
// at the moment that tkaSyncLock is taken).
tkaSyncLock sync.Mutex
clock tstime.Clock
// Last ClientVersion received in MapResponse, guarded by mu.
lastClientVersion *tailcfg.ClientVersion
}
type updateStatus struct {
started bool
}
// clientGen is a func that creates a control plane client.
@@ -280,6 +288,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
e := sys.Engine.Get()
store := sys.StateStore.Get()
dialer := sys.Dialer.Get()
_ = sys.MagicSock.Get() // or panic
pm, err := newProfileManager(store, logf)
if err != nil {
@@ -303,23 +312,24 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
clock := tstime.StdClock{}
b := &LocalBackend{
ctx: ctx,
ctxCancel: cancel,
logf: logf,
keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
sys: sys,
e: e,
dialer: dialer,
store: store,
pm: pm,
backendLogID: logID,
state: ipn.NoState,
portpoll: portpoll,
em: newExpiryManager(logf),
gotPortPollRes: make(chan struct{}),
loginFlags: loginFlags,
clock: clock,
ctx: ctx,
ctxCancel: cancel,
logf: logf,
keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
sys: sys,
e: e,
dialer: dialer,
store: store,
pm: pm,
backendLogID: logID,
state: ipn.NoState,
portpoll: portpoll,
em: newExpiryManager(logf),
gotPortPollRes: make(chan struct{}),
loginFlags: loginFlags,
clock: clock,
activeWatchSessions: make(set.Set[string]),
}
netMon := sys.NetMon.Get()
@@ -395,11 +405,7 @@ func (b *LocalBackend) SetComponentDebugLogging(component string, until time.Tim
var setEnabled func(bool)
switch component {
case "magicsock":
mc, err := b.magicConn()
if err != nil {
return err
}
setEnabled = mc.SetDebugLoggingEnabled
setEnabled = b.magicConn().SetDebugLoggingEnabled
case "sockstats":
if b.sockstatLogger != nil {
setEnabled = func(v bool) {
@@ -565,7 +571,14 @@ func (b *LocalBackend) Shutdown() {
b.mu.Unlock()
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
defer cancel()
b.LogoutSync(ctx) // best effort
t0 := time.Now()
err := b.Logout(ctx) // best effort
td := time.Since(t0).Round(time.Millisecond)
if err != nil {
b.logf("failed to log out ephemeral node on shutdown after %v: %v", td, err)
} else {
b.logf("logged out ephemeral node on shutdown")
}
b.mu.Lock()
}
cc := b.cc
@@ -657,6 +670,9 @@ func (b *LocalBackend) updateStatus(sb *ipnstate.StatusBuilder, extraLocked func
s.TUN = !b.sys.IsNetstack()
s.BackendState = b.state.String()
s.AuthURL = b.authURLSticky
if prefs := b.pm.CurrentPrefs(); prefs.Valid() && prefs.AutoUpdate().Check {
s.ClientVersion = b.lastClientVersion
}
if err := health.OverallError(); err != nil {
switch e := err.(type) {
case multierr.Error:
@@ -878,22 +894,18 @@ func (b *LocalBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
return nil
}
// SetDecompressor sets a decompression function, which must be a zstd
// reader.
//
// This exists because the iOS/Mac NetworkExtension is very resource
// constrained, and the zstd package is too heavy to fit in the
// constrained RSS limit.
func (b *LocalBackend) SetDecompressor(fn func() (controlclient.Decompressor, error)) {
b.newDecompressor = fn
}
// SetControlClientStatus is the callback invoked by the control client whenever it posts a new status.
// Among other things, this is where we update the netmap, packet filters, DNS and DERP maps.
func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st controlclient.Status) {
b.mu.Lock()
if b.cc != c {
b.logf("Ignoring SetControlClientStatus from old client")
b.mu.Unlock()
return
}
// The following do not depend on any data for which we need to lock b.
if st.Err != nil {
// TODO(crawshaw): display in the UI.
b.mu.Unlock()
if errors.Is(st.Err, io.EOF) {
b.logf("[v1] Received error: EOF")
return
@@ -910,8 +922,6 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
// Track the number of calls
currCall := b.numClientStatusCalls.Add(1)
b.mu.Lock()
// Handle node expiry in the netmap
if st.NetMap != nil {
now := b.clock.Now()
@@ -947,7 +957,7 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
// Call ourselves with the current status again; the logic in
// setClientStatus will take care of updating the expired field
// of peers in the netmap.
b.SetControlClientStatus(st)
b.SetControlClientStatus(c, st)
})
}
}
@@ -969,7 +979,7 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
b.blockEngineUpdates(false)
}
if st.LoginFinished != nil && wasBlocked {
if st.LoginFinished() && wasBlocked {
// Auth completed, unblock the engine
b.blockEngineUpdates(false)
b.authReconfig()
@@ -979,20 +989,6 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
// Lock b once and do only the things that require locking.
b.mu.Lock()
if st.LogoutFinished != nil {
if p := b.pm.CurrentPrefs(); !p.Persist().Valid() || p.Persist().UserProfile().LoginName() == "" {
b.mu.Unlock()
return
}
if err := b.pm.DeleteProfile(b.pm.CurrentProfile().ID); err != nil {
b.logf("error deleting profile: %v", err)
}
if err := b.resetForProfileChangeLockedOnEntry(); err != nil {
b.logf("resetForProfileChangeLockedOnEntry err: %v", err)
}
return
}
prefsChanged := false
prefs := b.pm.CurrentPrefs().AsStruct()
netMap := b.netMap
@@ -1007,8 +1003,8 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
prefs.ControlURL = prefs.ControlURLOrDefault()
prefsChanged = true
}
if st.Persist != nil && st.Persist.Valid() {
if !prefs.Persist.View().Equals(*st.Persist) {
if st.Persist.Valid() {
if !prefs.Persist.View().Equals(st.Persist) {
prefsChanged = true
prefs.Persist = st.Persist.AsStruct()
}
@@ -1017,7 +1013,7 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
b.authURL = st.URL
b.authURLSticky = st.URL
}
if wasBlocked && st.LoginFinished != nil {
if wasBlocked && st.LoginFinished() {
// Interactive login finished successfully (URL visited).
// After an interactive login, the user always wants
// WantRunning.
@@ -1102,7 +1098,7 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
}
b.e.SetNetworkMap(st.NetMap)
b.e.SetDERPMap(st.NetMap.DERPMap)
b.magicConn().SetDERPMap(st.NetMap.DERPMap)
// Update our cached DERP map
dnsfallback.UpdateCache(st.NetMap.DERPMap, b.logf)
@@ -1121,6 +1117,52 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
b.authReconfig()
}
var _ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil)
// UpdateNetmapDelta implements controlclient.NetmapDeltaUpdater.
func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bool) {
if !b.magicConn().UpdateNetmapDelta(muts) {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
return b.updateNetmapDeltaLocked(muts)
}
func (b *LocalBackend) updateNetmapDeltaLocked(muts []netmap.NodeMutation) (handled bool) {
if b.netMap == nil {
return false
}
peers := b.netMap.Peers
for _, m := range muts {
// LocalBackend only cares about some types of mutations.
// (magicsock cares about different ones.)
switch m.(type) {
case netmap.NodeMutationOnline, netmap.NodeMutationLastSeen:
default:
continue
}
nodeID := m.NodeIDBeingMutated()
idx := b.netMap.PeerIndexByNodeID(nodeID)
if idx == -1 {
continue
}
mut := peers[idx].AsStruct()
switch m := m.(type) {
case netmap.NodeMutationOnline:
mut.Online = ptr.To(m.Online)
case netmap.NodeMutationLastSeen:
mut.LastSeen = ptr.To(m.LastSeen)
}
peers[idx] = mut.View()
}
return true
}
// setExitNodeID updates prefs to reference an exit node by ID, rather
// than by IP. It returns whether prefs was mutated.
func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) {
@@ -1297,10 +1339,6 @@ func (b *LocalBackend) startIsNoopLocked(opts ipn.Options) bool {
// actually a supported operation (it should be, but it's very unclear
// from the following whether or not that is a safe transition).
func (b *LocalBackend) Start(opts ipn.Options) error {
if opts.LegacyMigrationPrefs == nil && !b.pm.CurrentPrefs().Valid() {
return errors.New("no prefs provided")
}
if opts.LegacyMigrationPrefs != nil {
b.logf("Start: %v", opts.LegacyMigrationPrefs.Pretty())
} else {
@@ -1308,6 +1346,11 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
}
b.mu.Lock()
if opts.LegacyMigrationPrefs == nil && !b.pm.CurrentPrefs().Valid() {
b.mu.Unlock()
return errors.New("no prefs provided")
}
if opts.UpdatePrefs != nil {
if err := b.checkPrefsLocked(opts.UpdatePrefs); err != nil {
b.mu.Unlock()
@@ -1326,7 +1369,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
// but meanwhile we can make Start cheaper here for such a
// case and not restart the world (which takes a few seconds).
// Instead, just send a notify with the state that iOS needs.
if b.startIsNoopLocked(opts) && profileID == b.lastProfileID {
if b.startIsNoopLocked(opts) && profileID == b.lastProfileID && profileID != "" {
b.logf("Start: already running; sending notify")
nm := b.netMap
state := b.state
@@ -1347,16 +1390,14 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
hostinfo.Userspace.Set(b.sys.IsNetstack())
hostinfo.UserspaceRouter.Set(b.sys.IsNetstackRouter())
if b.cc != nil {
// TODO(apenwarr): avoid the need to reinit controlclient.
// This will trigger a full relogin/reconfigure cycle every
// time a Handle reconnects to the backend. Ideally, we
// would send the new Prefs and everything would get back
// into sync with the minimal changes. But that's not how it
// is right now, which is a sign that the code is still too
// complicated.
b.resetControlClientLockedAsync()
}
// TODO(apenwarr): avoid the need to reinit controlclient.
// This will trigger a full relogin/reconfigure cycle every
// time a Handle reconnects to the backend. Ideally, we
// would send the new Prefs and everything would get back
// into sync with the minimal changes. But that's not how it
// is right now, which is a sign that the code is still too
// complicated.
prevCC := b.resetControlClientLocked()
httpTestClient := b.httpTestClient
if b.hostinfo != nil {
@@ -1385,6 +1426,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
wantRunning := prefs.WantRunning()
if wantRunning {
if err := b.initMachineKeyLocked(); err != nil {
b.mu.Unlock()
return fmt.Errorf("initMachineKeyLocked: %w", err)
}
}
@@ -1425,7 +1467,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
})
}
discoPublic := b.e.DiscoPublicKey()
discoPublic := b.magicConn().DiscoPublicKey()
var err error
@@ -1435,6 +1477,10 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
debugFlags = append([]string{"netstack"}, debugFlags...)
}
if prevCC != nil {
prevCC.Shutdown()
}
// TODO(apenwarr): The only way to change the ServerURL is to
// re-run b.Start(), because this is the only place we create a
// new controlclient. SetPrefs() allows you to overwrite ServerURL,
@@ -1446,8 +1492,6 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
ServerURL: serverURL,
AuthKey: opts.AuthKey,
Hostinfo: hostinfo,
KeepAlive: true,
NewDecompressor: b.newDecompressor,
HTTPTestClient: httpTestClient,
DiscoPublicKey: discoPublic,
DebugFlags: debugFlags,
@@ -1460,6 +1504,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
Observer: b,
C2NHandler: http.HandlerFunc(b.handleC2N),
DialPlan: &b.dialPlan, // pointer because it can't be copied
ControlKnobs: b.sys.ControlKnobs(),
// Don't warn about broken Linux IP forwarding when
// netstack is being used.
@@ -1470,6 +1515,13 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
}
b.mu.Lock()
// Even though we reset b.cc above, we might have raced with
// another Start() call. If so, shut down the previous one again
// as we do not know if it was created with the same options.
prevCC = b.resetControlClientLocked()
if prevCC != nil {
defer prevCC.Shutdown() // must be called after b.mu is unlocked
}
b.cc = cc
b.ccAuto, _ = cc.(*controlclient.Auto)
endpoints := b.endpoints
@@ -1493,7 +1545,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
}
cc.SetTKAHead(tkaHead)
b.e.SetNetInfoCallback(b.setNetInfo)
b.magicConn().SetNetInfoCallback(b.setNetInfo)
blid := b.backendLogID.String()
b.logf("Backend: logs: be:%v fe:%v", blid, opts.FrontendLogID)
@@ -1928,6 +1980,8 @@ func (b *LocalBackend) ResendHostinfoIfNeeded() {
func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWatchOpt, onWatchAdded func(), fn func(roNotify *ipn.Notify) (keepGoing bool)) {
ch := make(chan *ipn.Notify, 128)
sessionID := rands.HexString(16)
origFn := fn
if mask&ipn.NotifyNoPrivateKeys != 0 {
fn = func(n *ipn.Notify) bool {
@@ -1949,10 +2003,13 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
var ini *ipn.Notify
b.mu.Lock()
b.activeWatchSessions.Add(sessionID)
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap
if mask&initialBits != 0 {
ini = &ipn.Notify{Version: version.Long()}
if mask&ipn.NotifyInitialState != 0 {
ini.SessionID = sessionID
ini.State = ptr.To(b.state)
if b.state == ipn.NeedsLogin {
ini.BrowseToURL = ptr.To(b.authURLSticky)
@@ -1972,6 +2029,7 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
defer func() {
b.mu.Lock()
delete(b.notifyWatchers, handle)
delete(b.activeWatchSessions, sessionID)
b.mu.Unlock()
}()
@@ -2002,6 +2060,10 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
go b.pollRequestEngineStatus(ctx)
}
// TODO(marwan-at-work): check err
// TODO(marwan-at-work): streaming background logs?
defer b.DeleteForegroundSession(sessionID)
for {
select {
case <-ctx.Done():
@@ -2157,6 +2219,9 @@ func (b *LocalBackend) tellClientToBrowseToURL(url string) {
// onClientVersion is called on MapResponse updates when a MapResponse contains
// a non-nil ClientVersion message.
func (b *LocalBackend) onClientVersion(v *tailcfg.ClientVersion) {
b.mu.Lock()
b.lastClientVersion = v
b.mu.Unlock()
switch runtime.GOOS {
case "darwin", "ios":
// These auto-update well enough, and we haven't converted the
@@ -2765,7 +2830,7 @@ func (b *LocalBackend) SetPrefs(newp *ipn.Prefs) {
// doesn't affect security or correctness. And we also don't expect people to
// modify their ServeConfig in raw mode.
func (b *LocalBackend) wantIngressLocked() bool {
return b.serveConfig.Valid() && b.serveConfig.AllowFunnel().Len() > 0
return b.serveConfig.Valid() && b.serveConfig.HasAllowFunnel()
}
// setPrefsLockedOnEntry requires b.mu be held to call it, but it
@@ -2831,7 +2896,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
}
if netMap != nil {
b.e.SetDERPMap(netMap.DERPMap)
b.magicConn().SetDERPMap(netMap.DERPMap)
}
if !oldp.WantRunning() && newp.WantRunning {
@@ -3063,7 +3128,7 @@ func (b *LocalBackend) authReconfig() {
return
}
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, version.OS())
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.ControlKnobs(), version.OS())
rcfg := b.routerConfig(cfg, prefs, oneCGNATRoute)
dcfg := dnsConfigForNetmap(nm, prefs, b.logf, version.OS())
@@ -3081,11 +3146,13 @@ func (b *LocalBackend) authReconfig() {
//
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not
// a runtime.GOOS.
func shouldUseOneCGNATRoute(logf logger.Logf, versionOS string) bool {
// Explicit enabling or disabling always take precedence.
if v, ok := controlclient.ControlOneCGNATSetting().Get(); ok {
logf("[v1] shouldUseOneCGNATRoute: explicit=%v", v)
return v
func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs, versionOS string) bool {
if controlKnobs != nil {
// Explicit enabling or disabling always take precedence.
if v, ok := controlKnobs.OneCGNAT.Load().Get(); ok {
logf("[v1] shouldUseOneCGNATRoute: explicit=%v", v)
return v
}
}
// Also prefer to do this on the Mac, so that we don't need to constantly
@@ -3717,10 +3784,16 @@ func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State) {
}
// hasNodeKey reports whether a non-zero node key is present in the current
// prefs.
func (b *LocalBackend) hasNodeKey() bool {
// we can't use b.Prefs(), because it strips the keys, oops!
b.mu.Lock()
defer b.mu.Unlock()
return b.hasNodeKeyLocked()
}
func (b *LocalBackend) hasNodeKeyLocked() bool {
// we can't use b.Prefs(), because it strips the keys, oops!
p := b.pm.CurrentPrefs()
return p.Valid() && p.Persist().Valid() && !p.Persist().PrivateNodeKey().IsZero()
}
@@ -3729,19 +3802,17 @@ func (b *LocalBackend) hasNodeKey() bool {
func (b *LocalBackend) NodeKey() key.NodePublic {
b.mu.Lock()
defer b.mu.Unlock()
p := b.pm.CurrentPrefs()
if !p.Valid() || !p.Persist().Valid() || p.Persist().PrivateNodeKey().IsZero() {
if !b.hasNodeKeyLocked() {
return key.NodePublic{}
}
return p.Persist().PublicNodeKey()
return b.pm.CurrentPrefs().Persist().PublicNodeKey()
}
// nextState returns the state the backend seems to be in, based on
// nextStateLocked returns the state the backend seems to be in, based on
// its internal state.
func (b *LocalBackend) nextState() ipn.State {
b.mu.Lock()
//
// b.mu must be held
func (b *LocalBackend) nextStateLocked() ipn.State {
var (
cc = b.cc
netMap = b.netMap
@@ -3757,10 +3828,9 @@ func (b *LocalBackend) nextState() ipn.State {
wantRunning = p.WantRunning()
loggedOut = p.LoggedOut()
}
b.mu.Unlock()
switch {
case !wantRunning && !loggedOut && !blocked && b.hasNodeKey():
case !wantRunning && !loggedOut && !blocked && b.hasNodeKeyLocked():
return ipn.Stopped
case netMap == nil:
if (cc != nil && cc.AuthCantContinue()) || loggedOut {
@@ -3823,7 +3893,8 @@ func (b *LocalBackend) RequestEngineStatus() {
// TODO(apenwarr): use a channel or something to prevent reentrancy?
// Or maybe just call the state machine from fewer places.
func (b *LocalBackend) stateMachine() {
b.enterState(b.nextState())
b.mu.Lock()
b.enterStateLockedOnEntry(b.nextStateLocked())
}
// stopEngineAndWait deconfigures the local network data plane, and
@@ -3851,12 +3922,12 @@ func (b *LocalBackend) requestEngineStatusAndWait() {
b.statusLock.Unlock()
}
// resetControlClientLockedAsync sets b.cc to nil, and starts a
// goroutine to Shutdown the old client. It does not wait for the
// shutdown to complete.
func (b *LocalBackend) resetControlClientLockedAsync() {
// resetControlClientLocked sets b.cc to nil and returns the old value. If the
// returned value is non-nil, the caller must call Shutdown on it after
// releasing b.mu.
func (b *LocalBackend) resetControlClientLocked() controlclient.Client {
if b.cc == nil {
return
return nil
}
// When we clear the control client, stop any outstanding netmap expiry
@@ -3872,10 +3943,10 @@ func (b *LocalBackend) resetControlClientLockedAsync() {
// will abort.
b.numClientStatusCalls.Add(1)
}
go b.cc.Shutdown()
prev := b.cc
b.cc = nil
b.ccAuto = nil
return prev
}
// ResetForClientDisconnect resets the backend for GUI clients running
@@ -3885,11 +3956,15 @@ func (b *LocalBackend) resetControlClientLockedAsync() {
// don't want to the user to have to reauthenticate in the future
// when they restart the GUI.
func (b *LocalBackend) ResetForClientDisconnect() {
defer b.enterState(ipn.Stopped)
b.mu.Lock()
defer b.mu.Unlock()
b.logf("LocalBackend.ResetForClientDisconnect")
b.resetControlClientLockedAsync()
b.mu.Lock()
prevCC := b.resetControlClientLocked()
if prevCC != nil {
// Needs to happen without b.mu held.
defer prevCC.Shutdown()
}
b.setNetMapLocked(nil)
b.pm.Reset()
b.keyExpired = false
@@ -3897,6 +3972,7 @@ func (b *LocalBackend) ResetForClientDisconnect() {
b.authURLSticky = ""
b.activeLogin = ""
b.setAtomicValuesFromPrefsLocked(ipn.PrefsView{})
b.enterStateLockedOnEntry(ipn.Stopped)
}
func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Load() && envknob.CanSSHD() }
@@ -3911,27 +3987,30 @@ func (b *LocalBackend) ShouldHandleViaIP(ip netip.Addr) bool {
return false
}
// Logout tells the controlclient that we want to log out, and
// transitions the local engine to the logged-out state without
// waiting for controlclient to be in that state.
func (b *LocalBackend) Logout() {
b.logout(context.Background(), false)
}
func (b *LocalBackend) LogoutSync(ctx context.Context) error {
return b.logout(ctx, true)
}
func (b *LocalBackend) logout(ctx context.Context, sync bool) error {
// Logout logs out the current profile, if any, and waits for the logout to
// complete.
func (b *LocalBackend) Logout(ctx context.Context) error {
b.mu.Lock()
if !b.hasNodeKeyLocked() {
// Already logged out.
b.mu.Unlock()
return nil
}
cc := b.cc
// Grab the current profile before we unlock the mutex, so that we can
// delete it later.
profile := b.pm.CurrentProfile()
b.mu.Unlock()
b.EditPrefs(&ipn.MaskedPrefs{
_, err := b.EditPrefs(&ipn.MaskedPrefs{
WantRunningSet: true,
LoggedOutSet: true,
Prefs: ipn.Prefs{WantRunning: false, LoggedOut: true},
})
if err != nil {
return err
}
// Clear any previous dial plan(s), if set.
b.dialPlan.Store(nil)
@@ -3947,15 +4026,16 @@ func (b *LocalBackend) logout(ctx context.Context, sync bool) error {
return errors.New("no controlclient")
}
var err error
if sync {
err = cc.Logout(ctx)
} else {
cc.StartLogout()
if err := cc.Logout(ctx); err != nil {
return err
}
b.stateMachine()
return err
b.mu.Lock()
if err := b.pm.DeleteProfile(profile.ID); err != nil {
b.mu.Unlock()
b.logf("error deleting profile: %v", err)
return err
}
return b.resetForProfileChangeLockedOnEntry()
}
// assertClientLocked crashes if there is no controlclient in this backend.
@@ -3990,6 +4070,9 @@ func hasCapability(nm *netmap.NetworkMap, cap string) bool {
// Tailscale is turned off.
func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
b.dialer.SetNetMap(nm)
if ns, ok := b.sys.Netstack.GetOK(); ok {
ns.UpdateNetstackIPs(nm)
}
var login string
if nm != nil {
login = cmpx.Or(nm.UserProfiles[nm.User()].LoginName, "<missing-profile>")
@@ -3998,6 +4081,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
if login != b.activeLogin {
b.logf("active login: %v", login)
b.activeLogin = login
b.lastProfileID = b.pm.CurrentProfile().ID
}
b.pauseOrResumeControlClientLocked()
@@ -4067,6 +4151,10 @@ func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) {
}
}
// reloadServeConfigLocked reloads the serve config from the store or resets the
// serve config to nil if not logged in. The "changed" parameter, when false, instructs
// the method to only run the reset-logic and not reload the store from memory to ensure
// foreground sessions are not removed if they are not saved on disk.
func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
if b.netMap == nil || !b.netMap.SelfNode.Valid() || !prefs.Valid() || b.pm.CurrentProfile().ID == "" {
// We're not logged in, so we don't have a profile.
@@ -4075,6 +4163,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
b.serveConfig = ipn.ServeConfigView{}
return
}
confKey := ipn.ServeConfigKey(b.pm.CurrentProfile().ID)
// TODO(maisem,bradfitz): prevent reading the config from disk
// if the profile has not changed.
@@ -4094,6 +4183,12 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
b.serveConfig = ipn.ServeConfigView{}
return
}
// remove inactive sessions
maps.DeleteFunc(conf.Foreground, func(s string, sc *ipn.ServeConfig) bool {
return !b.activeWatchSessions.Contains(s)
})
b.serveConfig = conf.View()
}
@@ -4111,7 +4206,7 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
b.reloadServeConfigLocked(prefs)
if b.serveConfig.Valid() {
servePorts := make([]uint16, 0, 3)
b.serveConfig.TCP().Range(func(port uint16, _ ipn.TCPPortHandlerView) bool {
b.serveConfig.RangeOverTCPs(func(port uint16, _ ipn.TCPPortHandlerView) bool {
if port > 0 {
servePorts = append(servePorts, uint16(port))
}
@@ -4144,7 +4239,7 @@ func (b *LocalBackend) setServeProxyHandlersLocked() {
return
}
var backends map[string]bool
b.serveConfig.Web().Range(func(_ ipn.HostPort, conf ipn.WebServerConfigView) (cont bool) {
b.serveConfig.RangeOverWebs(func(_ ipn.HostPort, conf ipn.WebServerConfigView) (cont bool) {
conf.Handlers().Range(func(_ string, h ipn.HTTPHandlerView) (cont bool) {
backend := h.Proxy()
if backend == "" {
@@ -4600,29 +4695,22 @@ func peerCanProxyDNS(p tailcfg.NodeView) bool {
}
func (b *LocalBackend) DebugRebind() error {
mc, err := b.magicConn()
if err != nil {
return err
}
mc.Rebind()
b.magicConn().Rebind()
return nil
}
func (b *LocalBackend) DebugReSTUN() error {
mc, err := b.magicConn()
if err != nil {
return err
}
mc.ReSTUN("explicit-debug")
b.magicConn().ReSTUN("explicit-debug")
return nil
}
func (b *LocalBackend) magicConn() (*magicsock.Conn, error) {
mc, ok := b.sys.MagicSock.GetOK()
if !ok {
return nil, errors.New("failed to get magicsock from sys")
}
return mc, nil
// ControlKnobs returns the node's control knobs.
func (b *LocalBackend) ControlKnobs() *controlknobs.Knobs {
return b.sys.ControlKnobs()
}
func (b *LocalBackend) magicConn() *magicsock.Conn {
return b.sys.MagicSock.Get()
}
type keyProvingNoiseRoundTripper struct {
@@ -4928,16 +5016,26 @@ func (b *LocalBackend) initTKALocked() error {
}
// resetForProfileChangeLockedOnEntry resets the backend for a profile change.
//
// b.mu must held on entry. It is released on exit.
func (b *LocalBackend) resetForProfileChangeLockedOnEntry() error {
if b.shutdownCalled {
// Prevent a call back to Start during Shutdown, which calls Logout for
// ephemeral nodes, which can then call back here. But we're shutting
// down, so no need to do any work.
b.mu.Unlock()
return nil
}
b.setNetMapLocked(nil) // Reset netmap.
// Reset the NetworkMap in the engine
b.e.SetNetworkMap(new(netmap.NetworkMap))
if err := b.initTKALocked(); err != nil {
b.mu.Unlock()
return err
}
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
b.enterStateLockedOnEntry(ipn.NoState) // Reset state.
b.enterStateLockedOnEntry(ipn.NoState) // Reset state; releases b.mu
health.SetLocalLogConfigHealth(nil)
return b.Start(ipn.Options{})
}
@@ -4988,7 +5086,10 @@ func (b *LocalBackend) ListProfiles() []ipn.LoginProfile {
// called to register it as new node.
func (b *LocalBackend) ResetAuth() error {
b.mu.Lock()
b.resetControlClientLockedAsync()
prevCC := b.resetControlClientLocked()
if prevCC != nil {
defer prevCC.Shutdown() // call must happen after release b.mu
}
if err := b.clearMachineKeyLocked(); err != nil {
b.mu.Unlock()
return err
@@ -5052,12 +5153,7 @@ func (b *LocalBackend) GetPeerEndpointChanges(ctx context.Context, ip netip.Addr
}
peer := pip.Node
mc, err := b.magicConn()
if err != nil {
return nil, fmt.Errorf("getting magicsock conn: %w", err)
}
chs, err := mc.GetEndpointChanges(peer)
chs, err := b.magicConn().GetEndpointChanges(peer)
if err != nil {
return nil, fmt.Errorf("getting endpoint changes: %w", err)
}
@@ -5074,9 +5170,5 @@ func (b *LocalBackend) DebugBreakTCPConns() error {
}
func (b *LocalBackend) DebugBreakDERPConns() error {
mc, err := b.magicConn()
if err != nil {
return err
}
return mc.DebugBreakDERPConns()
return b.magicConn().DebugBreakDERPConns()
}

View File

@@ -26,6 +26,8 @@ import (
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/netmap"
"tailscale.com/types/ptr"
"tailscale.com/util/set"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/wgcfg"
@@ -826,9 +828,6 @@ type legacyBackend interface {
StartLoginInteractive()
// Login logs in with an OAuth2 token.
Login(token *tailcfg.Oauth2Token)
// Logout terminates the current login session and stops the
// wireguard engine.
Logout()
// SetPrefs installs a new set of user preferences, including
// WantRunning. This may cause the wireguard engine to
// reconfigure or stop.
@@ -846,6 +845,9 @@ var _ legacyBackend = (*LocalBackend)(nil)
func TestWatchNotificationsCallbacks(t *testing.T) {
b := new(LocalBackend)
// activeWatchSessions is typically set in NewLocalBackend
// so WatchNotifications expects it to be non-empty.
b.activeWatchSessions = make(set.Set[string])
n := new(ipn.Notify)
b.WatchNotifications(context.Background(), 0, func() {
b.mu.Lock()
@@ -878,3 +880,75 @@ func TestWatchNotificationsCallbacks(t *testing.T) {
t.Fatalf("unexpected number of watchers in new LocalBackend, want: 0 got: %v", len(b.notifyWatchers))
}
}
// tests LocalBackend.updateNetmapDeltaLocked
func TestUpdateNetmapDelta(t *testing.T) {
var b LocalBackend
if b.updateNetmapDeltaLocked(nil) {
t.Errorf("updateNetmapDeltaLocked() = true, want false with nil netmap")
}
b.netMap = &netmap.NetworkMap{}
for i := 0; i < 5; i++ {
b.netMap.Peers = append(b.netMap.Peers, (&tailcfg.Node{ID: (tailcfg.NodeID(i) + 1)}).View())
}
someTime := time.Unix(123, 0)
muts, ok := netmap.MutationsFromMapResponse(&tailcfg.MapResponse{
PeersChangedPatch: []*tailcfg.PeerChange{
{
NodeID: 1,
DERPRegion: 1,
},
{
NodeID: 2,
Online: ptr.To(true),
},
{
NodeID: 3,
Online: ptr.To(false),
},
{
NodeID: 4,
LastSeen: ptr.To(someTime),
},
},
}, someTime)
if !ok {
t.Fatal("netmap.MutationsFromMapResponse failed")
}
if !b.updateNetmapDeltaLocked(muts) {
t.Fatalf("updateNetmapDeltaLocked() = false, want true with new netmap")
}
wants := []*tailcfg.Node{
{
ID: 1,
DERP: "", // unmodified by the delta
},
{
ID: 2,
Online: ptr.To(true),
},
{
ID: 3,
Online: ptr.To(false),
},
{
ID: 4,
LastSeen: ptr.To(someTime),
},
}
for _, want := range wants {
idx := b.netMap.PeerIndexByNodeID(want.ID)
if idx == -1 {
t.Errorf("ID %v not found in netmap", want.ID)
continue
}
got := b.netMap.Peers[idx].AsStruct()
if !reflect.DeepEqual(got, want) {
t.Errorf("netmap.Peer %v wrong.\n got: %v\nwant: %v", want.ID, logger.AsJSON(got), logger.AsJSON(want))
}
}
}

View File

@@ -31,7 +31,7 @@ import (
type observerFunc func(controlclient.Status)
func (f observerFunc) SetControlClientStatus(s controlclient.Status) {
func (f observerFunc) SetControlClientStatus(_ controlclient.Client, s controlclient.Status) {
f(s)
}

View File

@@ -1234,11 +1234,7 @@ func (h *peerAPIHandler) handleServeMagicsock(w http.ResponseWriter, r *http.Req
http.Error(w, "denied; no debug access", http.StatusForbidden)
return
}
if mc, ok := h.ps.b.sys.MagicSock.GetOK(); ok {
mc.ServeHTTPDebug(w, r)
return
}
http.Error(w, "miswired", 500)
h.ps.b.magicConn().ServeHTTPDebug(w, r)
}
func (h *peerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Request) {

View File

@@ -28,6 +28,8 @@ var debug = envknob.RegisterBool("TS_DEBUG_PROFILES")
// profileManager is a wrapper around a StateStore that manages
// multiple profiles and the current profile.
//
// It is not safe for concurrent use.
type profileManager struct {
store ipn.StateStore
logf logger.Logf
@@ -439,6 +441,7 @@ func (pm *profileManager) NewProfile() {
// defaultPrefs is the default prefs for a new profile.
var defaultPrefs = func() ipn.PrefsView {
prefs := ipn.NewPrefs()
prefs.LoggedOut = true
prefs.WantRunning = false
prefs.ControlURL = winutil.GetPolicyString("LoginURL", "")

View File

@@ -5,7 +5,9 @@ package ipnlocal
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -23,7 +25,6 @@ import (
"sync"
"time"
"github.com/google/uuid"
"tailscale.com/ipn"
"tailscale.com/logtail/backoff"
"tailscale.com/net/netutil"
@@ -34,6 +35,11 @@ import (
"tailscale.com/version"
)
// ErrETagMismatch signals that the given
// If-Match header does not match with the
// current etag of a resource.
var ErrETagMismatch = errors.New("etag mismatch")
// serveHTTPContextKey is the context.Value key for a *serveHTTPContext.
type serveHTTPContextKey struct{}
@@ -215,10 +221,15 @@ func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint1
}
// SetServeConfig establishes or replaces the current serve config.
func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error {
// ETag is an optional parameter to enforce Optimistic Concurrency Control.
// If it is an empty string, then the config will be overwritten.
func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig, etag string) error {
b.mu.Lock()
defer b.mu.Unlock()
return b.setServeConfigLocked(config, etag)
}
func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string) error {
prefs := b.pm.CurrentPrefs()
if config.IsFunnelOn() && prefs.ShieldsUp() {
return errors.New("Unable to turn on Funnel while shields-up is enabled")
@@ -231,8 +242,24 @@ func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error {
if !nm.SelfNode.Valid() {
return errors.New("netMap SelfNode is nil")
}
profileID := b.pm.CurrentProfile().ID
confKey := ipn.ServeConfigKey(profileID)
// If etag is present, check that it has
// not changed from the last config.
if etag != "" {
// Note that we marshal b.serveConfig
// and not use b.lastServeConfJSON as that might
// be a Go nil value, which produces a different
// checksum from a JSON "null" value.
previousCfg, err := json.Marshal(b.serveConfig)
if err != nil {
return fmt.Errorf("error encoding previous config: %w", err)
}
sum := sha256.Sum256(previousCfg)
previousEtag := hex.EncodeToString(sum[:])
if etag != previousEtag {
return ErrETagMismatch
}
}
var bs []byte
if config != nil {
@@ -242,6 +269,9 @@ func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error {
}
bs = j
}
profileID := b.pm.CurrentProfile().ID
confKey := ipn.ServeConfigKey(profileID)
if err := b.store.WriteState(confKey, bs); err != nil {
return fmt.Errorf("writing ServeConfig to StateStore: %w", err)
}
@@ -258,162 +288,18 @@ func (b *LocalBackend) ServeConfig() ipn.ServeConfigView {
return b.serveConfig
}
// StreamServe opens a stream to write any incoming connections made
// to the given HostPort out to the listening io.Writer.
//
// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig,
// the backend enables it for the duration of the context's lifespan and
// then turns it back off once the context is closed. If either are already enabled,
// then they remain that way but logs are still streamed
func (b *LocalBackend) StreamServe(ctx context.Context, w io.Writer, req ipn.ServeStreamRequest) (err error) {
f, ok := w.(http.Flusher)
if !ok {
return errors.New("writer not a flusher")
}
f.Flush()
port, err := req.HostPort.Port()
if err != nil {
return err
}
// Turn on Funnel for the given HostPort.
sc := b.ServeConfig().AsStruct()
if sc == nil {
sc = &ipn.ServeConfig{}
}
setHandler(sc, req)
if err := b.SetServeConfig(sc); err != nil {
return fmt.Errorf("errro setting serve config: %w", err)
}
// Defer turning off Funnel once stream ends.
defer func() {
sc := b.ServeConfig().AsStruct()
deleteHandler(sc, req, port)
err = errors.Join(err, b.SetServeConfig(sc))
}()
var writeErrs []error
writeToStream := func(log ipn.FunnelRequestLog) {
jsonLog, err := json.Marshal(log)
if err != nil {
writeErrs = append(writeErrs, err)
return
}
if _, err := fmt.Fprintf(w, "%s\n", jsonLog); err != nil {
writeErrs = append(writeErrs, err)
return
}
f.Flush()
}
// Hook up connections stream.
// DeleteForegroundSession deletes a ServeConfig's foreground session
// in the LocalBackend if it exists. It also ensures check, delete, and
// set operations happen within the same mutex lock to avoid any races.
func (b *LocalBackend) DeleteForegroundSession(sessionID string) error {
b.mu.Lock()
mak.NonNilMapForJSON(&b.serveStreamers)
if b.serveStreamers[port] == nil {
b.serveStreamers[port] = make(map[uint32]func(ipn.FunnelRequestLog))
}
id := uuid.New().ID()
b.serveStreamers[port][id] = writeToStream
b.mu.Unlock()
// Clean up streamer when done.
defer func() {
b.mu.Lock()
delete(b.serveStreamers[port], id)
b.mu.Unlock()
}()
select {
case <-ctx.Done():
// Triggered by foreground `tailscale funnel` process
// (the streamer) getting closed, or by turning off Tailscale.
}
return errors.Join(writeErrs...)
}
func setHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest) {
if sc.TCP == nil {
sc.TCP = make(map[uint16]*ipn.TCPPortHandler)
}
if _, ok := sc.TCP[443]; !ok {
sc.TCP[443] = &ipn.TCPPortHandler{
HTTPS: true,
}
}
if sc.Web == nil {
sc.Web = make(map[ipn.HostPort]*ipn.WebServerConfig)
}
wsc, ok := sc.Web[req.HostPort]
if !ok {
wsc = &ipn.WebServerConfig{}
sc.Web[req.HostPort] = wsc
}
if wsc.Handlers == nil {
wsc.Handlers = make(map[string]*ipn.HTTPHandler)
}
wsc.Handlers[req.MountPoint] = &ipn.HTTPHandler{
Proxy: req.Source,
}
if sc.AllowFunnel == nil {
sc.AllowFunnel = make(map[ipn.HostPort]bool)
}
sc.AllowFunnel[req.HostPort] = true
}
func deleteHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest, port uint16) {
delete(sc.AllowFunnel, req.HostPort)
if sc.TCP != nil {
delete(sc.TCP, port)
}
if sc.Web == nil {
return
}
if sc.Web[req.HostPort] == nil {
return
}
wsc, ok := sc.Web[req.HostPort]
if !ok {
return
}
if wsc.Handlers == nil {
return
}
if _, ok := wsc.Handlers[req.MountPoint]; !ok {
return
}
delete(wsc.Handlers, req.MountPoint)
if len(wsc.Handlers) == 0 {
delete(sc.Web, req.HostPort)
}
}
func (b *LocalBackend) maybeLogServeConnection(destPort uint16, srcAddr netip.AddrPort) {
b.mu.Lock()
streamers := b.serveStreamers[destPort]
b.mu.Unlock()
if len(streamers) == 0 {
return
}
var log ipn.FunnelRequestLog
log.SrcAddr = srcAddr
log.Time = b.clock.Now()
if node, user, ok := b.WhoIs(srcAddr); ok {
log.NodeName = node.ComputedName()
if node.IsTagged() {
log.NodeTags = node.Tags().AsSlice()
} else {
log.UserLoginName = user.LoginName
log.UserDisplayName = user.DisplayName
}
}
for _, stream := range streamers {
stream(log)
defer b.mu.Unlock()
if !b.serveConfig.Valid() || !b.serveConfig.Foreground().Has(sessionID) {
return nil
}
sc := b.serveConfig.AsStruct()
delete(sc.Foreground, sessionID)
return b.setServeConfigLocked(sc, "")
}
func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target ipn.HostPort, srcAddr netip.AddrPort, getConnOrReset func() (net.Conn, bool), sendRST func()) {
@@ -427,7 +313,7 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
return
}
if !sc.AllowFunnel().Get(target) {
if !sc.HasFunnelForTarget(target) {
b.logf("localbackend: got ingress conn for unconfigured %q; rejecting", target)
sendRST()
return
@@ -485,7 +371,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
return nil
}
tcph, ok := sc.TCP().GetOk(dport)
tcph, ok := sc.FindTCP(dport)
if !ok {
b.logf("[unexpected] localbackend: got TCP conn without TCP config for port %v; from %v", dport, srcAddr)
return nil
@@ -518,7 +404,6 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
if backDst := tcph.TCPForward(); backDst != "" {
return func(conn net.Conn) error {
defer conn.Close()
b.maybeLogServeConnection(dport, srcAddr)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
cancel()
@@ -681,15 +566,14 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) {
r.Out.Header.Set("Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers")
}
// serveWebHandler is an http.HandlerFunc that maps incoming requests to the
// correct *http.
func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
h, mountPoint, ok := b.getServeHandler(r)
if !ok {
http.NotFound(w, r)
return
}
if c, ok := getServeHTTPContext(r); ok {
b.maybeLogServeConnection(c.DestPort, c.SrcAddr)
}
if s := h.Text(); s != "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
io.WriteString(w, s)
@@ -825,7 +709,7 @@ func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebS
if !b.serveConfig.Valid() {
return c, false
}
return b.serveConfig.Web().GetOk(key)
return b.serveConfig.FindWeb(key)
}
func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {

View File

@@ -6,7 +6,11 @@ package ipnlocal
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -24,6 +28,7 @@ import (
"tailscale.com/types/logid"
"tailscale.com/types/netmap"
"tailscale.com/util/cmpx"
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/wgengine"
)
@@ -169,50 +174,82 @@ func TestGetServeHandler(t *testing.T) {
}
}
func TestServeHTTPProxy(t *testing.T) {
sys := &tsd.System{}
e, err := wgengine.NewUserspaceEngine(t.Logf, wgengine.Config{SetSubsystem: sys.Set})
func getEtag(t *testing.T, b any) string {
t.Helper()
bts, err := json.Marshal(b)
if err != nil {
t.Fatal(err)
}
sys.Set(e)
sys.Set(new(mem.Store))
b, err := NewLocalBackend(t.Logf, logid.PublicID{}, sys, 0)
sum := sha256.Sum256(bts)
return hex.EncodeToString(sum[:])
}
func TestServeConfigETag(t *testing.T) {
b := newTestBackend(t)
// a nil config with initial etag should succeed
err := b.SetServeConfig(nil, getEtag(t, nil))
if err != nil {
t.Fatal(err)
}
defer b.Shutdown()
dir := t.TempDir()
b.SetVarRoot(dir)
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
pm.currentProfile = &ipn.LoginProfile{ID: "id0"}
b.pm = pm
// a nil config with an invalid etag should fail
err = b.SetServeConfig(nil, "abc")
if !errors.Is(err, ErrETagMismatch) {
t.Fatal("expected an error but got nil")
}
b.netMap = &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "example.ts.net",
}).View(),
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
tailcfg.UserID(1): {
LoginName: "someone@example.com",
DisplayName: "Some One",
ProfilePicURL: "https://example.com/photo.jpg",
},
// a new config with no etag should succeed
conf := &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
}
b.nodeByAddr = map[netip.Addr]tailcfg.NodeView{
netip.MustParseAddr("100.150.151.152"): (&tailcfg.Node{
ComputedName: "some-peer",
User: tailcfg.UserID(1),
}).View(),
netip.MustParseAddr("100.150.151.153"): (&tailcfg.Node{
ComputedName: "some-tagged-peer",
Tags: []string{"tag:server", "tag:test"},
User: tailcfg.UserID(1),
}).View(),
err = b.SetServeConfig(conf, getEtag(t, nil))
if err != nil {
t.Fatal(err)
}
confView := b.ServeConfig()
etag := getEtag(t, confView)
if etag == "" {
t.Fatal("expected to get an etag but got an empty string")
}
conf = confView.AsStruct()
mak.Set(&conf.AllowFunnel, "example.ts.net:443", true)
// replacing an existing config with an invalid etag should fail
err = b.SetServeConfig(conf, "invalid etag")
if !errors.Is(err, ErrETagMismatch) {
t.Fatalf("expected an etag mismatch error but got %v", err)
}
// replacing an existing config with a valid etag should succeed
err = b.SetServeConfig(conf, etag)
if err != nil {
t.Fatal(err)
}
// replacing an existing config with a previous etag should fail
err = b.SetServeConfig(nil, etag)
if !errors.Is(err, ErrETagMismatch) {
t.Fatalf("expected an etag mismatch error but got %v", err)
}
// replacing an existing config with the new etag should succeed
newCfg := b.ServeConfig()
etag = getEtag(t, newCfg)
err = b.SetServeConfig(nil, etag)
if err != nil {
t.Fatal(err)
}
}
func TestServeHTTPProxy(t *testing.T) {
b := newTestBackend(t)
// Start test serve endpoint.
testServ := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
@@ -232,7 +269,7 @@ func TestServeHTTPProxy(t *testing.T) {
}},
},
}
if err := b.SetServeConfig(conf); err != nil {
if err := b.SetServeConfig(conf, ""); err != nil {
t.Fatal(err)
}
@@ -309,6 +346,52 @@ func TestServeHTTPProxy(t *testing.T) {
}
}
func newTestBackend(t *testing.T) *LocalBackend {
sys := &tsd.System{}
e, err := wgengine.NewUserspaceEngine(t.Logf, wgengine.Config{SetSubsystem: sys.Set})
if err != nil {
t.Fatal(err)
}
sys.Set(e)
sys.Set(new(mem.Store))
b, err := NewLocalBackend(t.Logf, logid.PublicID{}, sys, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(b.Shutdown)
dir := t.TempDir()
b.SetVarRoot(dir)
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
pm.currentProfile = &ipn.LoginProfile{ID: "id0"}
b.pm = pm
b.netMap = &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "example.ts.net",
}).View(),
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
tailcfg.UserID(1): {
LoginName: "someone@example.com",
DisplayName: "Some One",
ProfilePicURL: "https://example.com/photo.jpg",
},
},
}
b.nodeByAddr = map[netip.Addr]tailcfg.NodeView{
netip.MustParseAddr("100.150.151.152"): (&tailcfg.Node{
ComputedName: "some-peer",
User: tailcfg.UserID(1),
}).View(),
netip.MustParseAddr("100.150.151.153"): (&tailcfg.Node{
ComputedName: "some-tagged-peer",
Tags: []string{"tag:server", "tag:test"},
User: tailcfg.UserID(1),
}).View(),
}
return b
}
func TestServeFileOrDirectory(t *testing.T) {
td := t.TempDir()
writeFile := func(suffix, contents string) {

View File

@@ -19,7 +19,6 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/types/empty"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
@@ -163,19 +162,18 @@ func (cc *mockControl) send(err error, url string, loginFinished bool, nm *netma
cc.mu.Unlock()
}
if cc.opts.Observer != nil {
pv := cc.persist.View()
s := controlclient.Status{
URL: url,
NetMap: nm,
Persist: &pv,
Persist: cc.persist.View(),
Err: err,
}
if loginFinished {
s.LoginFinished = &empty.Message{}
s.SetStateForTest(controlclient.StateAuthenticated)
} else if url == "" && err == nil && nm == nil {
s.LogoutFinished = &empty.Message{}
s.SetStateForTest(controlclient.StateNotAuthenticated)
}
cc.opts.Observer.SetControlClientStatus(s)
cc.opts.Observer.SetControlClientStatus(cc, s)
}
}
@@ -219,11 +217,6 @@ func (cc *mockControl) Login(t *tailcfg.Oauth2Token, flags controlclient.LoginFl
cc.authBlocked = interact || newKeys
}
func (cc *mockControl) StartLogout() {
cc.logf("StartLogout")
cc.called("StartLogout")
}
func (cc *mockControl) Logout(ctx context.Context) error {
cc.logf("Logout")
cc.called("Logout")
@@ -331,10 +324,10 @@ func TestStateMachine(t *testing.T) {
(n.Prefs != nil && n.Prefs.Valid()) ||
n.BrowseToURL != nil ||
n.LoginFinished != nil {
logf("\n%v\n\n", n)
logf("%v\n\n", n)
notifies.put(n)
} else {
logf("\n(ignored) %v\n\n", n)
logf("(ignored) %v\n\n", n)
}
})
@@ -360,7 +353,7 @@ func TestStateMachine(t *testing.T) {
// Note: a totally fresh system has Prefs.LoggedOut=false by
// default. We are logged out, but not because the user asked
// for it, so it doesn't count as Prefs.LoggedOut==true.
c.Assert(prefs.LoggedOut(), qt.IsFalse)
c.Assert(prefs.LoggedOut(), qt.IsTrue)
c.Assert(prefs.WantRunning(), qt.IsFalse)
c.Assert(ipn.NeedsLogin, qt.Equals, *nn[1].State)
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
@@ -381,7 +374,7 @@ func TestStateMachine(t *testing.T) {
cc.assertCalls()
c.Assert(nn[0].Prefs, qt.IsNotNil)
c.Assert(nn[1].State, qt.IsNotNil)
c.Assert(nn[0].Prefs.LoggedOut(), qt.IsFalse)
c.Assert(nn[0].Prefs.LoggedOut(), qt.IsTrue)
c.Assert(nn[0].Prefs.WantRunning(), qt.IsFalse)
c.Assert(ipn.NeedsLogin, qt.Equals, *nn[1].State)
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
@@ -419,7 +412,7 @@ func TestStateMachine(t *testing.T) {
nn := notifies.drain(1)
c.Assert(nn[0].Prefs, qt.IsNotNil)
c.Assert(nn[0].Prefs.LoggedOut(), qt.IsFalse)
c.Assert(nn[0].Prefs.LoggedOut(), qt.IsTrue)
c.Assert(nn[0].Prefs.WantRunning(), qt.IsFalse)
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
}
@@ -585,85 +578,52 @@ func TestStateMachine(t *testing.T) {
// User wants to logout.
store.awaitWrite()
t.Logf("\n\nLogout (async)")
notifies.expect(2)
b.Logout()
t.Logf("\n\nLogout")
notifies.expect(5)
b.Logout(context.Background())
{
nn := notifies.drain(2)
cc.assertCalls("pause", "StartLogout")
nn := notifies.drain(5)
previousCC.assertCalls("pause", "Logout", "unpause", "Shutdown")
c.Assert(nn[0].State, qt.IsNotNil)
c.Assert(*nn[0].State, qt.Equals, ipn.Stopped)
c.Assert(nn[1].Prefs, qt.IsNotNil)
c.Assert(ipn.Stopped, qt.Equals, *nn[0].State)
c.Assert(nn[1].Prefs.LoggedOut(), qt.IsTrue)
c.Assert(nn[1].Prefs.WantRunning(), qt.IsFalse)
c.Assert(ipn.Stopped, qt.Equals, b.State())
cc.assertCalls("New")
c.Assert(nn[2].State, qt.IsNotNil)
c.Assert(*nn[2].State, qt.Equals, ipn.NoState)
c.Assert(nn[3].Prefs, qt.IsNotNil) // emptyPrefs
c.Assert(nn[3].Prefs.LoggedOut(), qt.IsTrue)
c.Assert(nn[3].Prefs.WantRunning(), qt.IsFalse)
c.Assert(nn[4].State, qt.IsNotNil)
c.Assert(*nn[4].State, qt.Equals, ipn.NeedsLogin)
c.Assert(b.State(), qt.Equals, ipn.NeedsLogin)
c.Assert(store.sawWrite(), qt.IsTrue)
}
// Let's make the logout succeed.
t.Logf("\n\nLogout (async) - succeed")
// A second logout should be a no-op as we are in the NeedsLogin state.
t.Logf("\n\nLogout2")
notifies.expect(0)
b.Logout(context.Background())
{
notifies.drain(0)
cc.assertCalls()
c.Assert(b.Prefs().LoggedOut(), qt.IsTrue)
c.Assert(b.Prefs().WantRunning(), qt.IsFalse)
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
}
// A third logout should also be a no-op as the cc should be in
// AuthCantContinue state.
t.Logf("\n\nLogout3")
notifies.expect(3)
cc.send(nil, "", false, nil)
{
previousCC.assertShutdown(true)
nn := notifies.drain(3)
cc.assertCalls("New")
c.Assert(nn[0].State, qt.IsNotNil)
c.Assert(*nn[0].State, qt.Equals, ipn.NoState)
c.Assert(nn[1].Prefs, qt.IsNotNil) // emptyPrefs
c.Assert(nn[2].State, qt.IsNotNil)
c.Assert(*nn[2].State, qt.Equals, ipn.NeedsLogin)
c.Assert(b.Prefs().LoggedOut(), qt.IsFalse)
c.Assert(b.Prefs().WantRunning(), qt.IsFalse)
c.Assert(b.State(), qt.Equals, ipn.NeedsLogin)
}
// A second logout should reset all prefs.
t.Logf("\n\nLogout2 (async)")
notifies.expect(1)
b.Logout()
{
nn := notifies.drain(1)
c.Assert(nn[0].Prefs, qt.IsNotNil) // emptyPrefs
// BUG: the backend has already called StartLogout, and we're
// still logged out. So it shouldn't call it again.
cc.assertCalls("StartLogout")
cc.assertCalls()
c.Assert(b.Prefs().LoggedOut(), qt.IsTrue)
c.Assert(b.Prefs().WantRunning(), qt.IsFalse)
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
}
// Let's acknowledge the second logout too.
t.Logf("\n\nLogout2 (async) - succeed")
notifies.expect(0)
cc.send(nil, "", false, nil)
{
notifies.drain(0)
cc.assertCalls()
c.Assert(b.Prefs().LoggedOut(), qt.IsTrue)
c.Assert(b.Prefs().WantRunning(), qt.IsFalse)
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
}
// Try the synchronous logout feature.
t.Logf("\n\nLogout3 (sync)")
notifies.expect(0)
b.LogoutSync(context.Background())
// NOTE: This returns as soon as cc.Logout() returns, which is okay
// I guess, since that's supposed to be synchronous.
{
notifies.drain(0)
cc.assertCalls("Logout")
c.Assert(b.Prefs().LoggedOut(), qt.IsTrue)
c.Assert(b.Prefs().WantRunning(), qt.IsFalse)
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
}
// Generate the third logout event.
t.Logf("\n\nLogout3 (sync) - succeed")
notifies.expect(0)
cc.send(nil, "", false, nil)
b.Logout(context.Background())
{
notifies.drain(0)
cc.assertCalls()

View File

@@ -12,6 +12,7 @@ import (
"io"
"log"
"net/netip"
"slices"
"sort"
"strings"
"sync"
@@ -69,8 +70,17 @@ type Status struct {
// trailing periods, and without any "_acme-challenge." prefix.
CertDomains []string
// Peer is the state of each peer, keyed by each peer's current public key.
Peer map[key.NodePublic]*PeerStatus
// User contains profile information about UserIDs referenced by
// PeerStatus.UserID, PeerStatus.AltSharerUserID, etc.
User map[tailcfg.UserID]tailcfg.UserProfile
// ClientVersion, when non-nil, contains information about the latest
// version of the Tailscale client that's available. Depending on
// the platform and client settings, it may not be available.
ClientVersion *tailcfg.ClientVersion
}
// TKAKey describes a key trusted by network lock.
@@ -188,6 +198,7 @@ type PeerStatusLite struct {
NodeKey key.NodePublic
}
// PeerStatus describes a peer node and its current state.
type PeerStatus struct {
ID tailcfg.StableNodeID
PublicKey key.NodePublic
@@ -281,6 +292,9 @@ type PeerStatus struct {
Location *tailcfg.Location `json:",omitempty"`
}
// StatusBuilder is a request to construct a Status. A new StatusBuilder is
// passed to various subsystems which then call methods on it to populate state.
// Call its Status method to return the final constructed Status.
type StatusBuilder struct {
WantPeers bool // whether caller wants peers
@@ -301,6 +315,8 @@ func (sb *StatusBuilder) MutateStatus(f func(*Status)) {
f(&sb.st)
}
// Status returns the status that has been built up so far from previous
// calls to MutateStatus, MutateSelfStatus, AddPeer, etc.
func (sb *StatusBuilder) Status() *Status {
sb.mu.Lock()
defer sb.mu.Unlock()
@@ -665,23 +681,29 @@ func (pr *PingResult) ToPingResponse(pingType tailcfg.PingType) *tailcfg.PingRes
}
}
// SortPeers sorts peers by either their DNS name, hostname, Tailscale IP,
// or ultimately their current public key.
func SortPeers(peers []*PeerStatus) {
sort.Slice(peers, func(i, j int) bool { return sortKey(peers[i]) < sortKey(peers[j]) })
slices.SortStableFunc(peers, (*PeerStatus).compare)
}
func sortKey(ps *PeerStatus) string {
if ps.DNSName != "" {
return ps.DNSName
func (a *PeerStatus) compare(b *PeerStatus) int {
if a.DNSName != "" || b.DNSName != "" {
if v := strings.Compare(a.DNSName, b.DNSName); v != 0 {
return v
}
}
if ps.HostName != "" {
return ps.HostName
if a.HostName != "" || b.HostName != "" {
if v := strings.Compare(a.HostName, b.HostName); v != 0 {
return v
}
}
// TODO(bradfitz): add PeerStatus.Less and avoid these allocs in a Less func.
if len(ps.TailscaleIPs) > 0 {
return ps.TailscaleIPs[0].String()
if len(a.TailscaleIPs) > 0 && len(b.TailscaleIPs) > 0 {
if v := a.TailscaleIPs[0].Compare(b.TailscaleIPs[0]); v != 0 {
return v
}
}
raw := ps.PublicKey.Raw32()
return string(raw[:])
return a.PublicKey.Compare(b.PublicKey)
}
// DebugDERPRegionReport is the result of a "tailscale debug derp" command,

View File

@@ -7,7 +7,7 @@ package localapi
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@@ -49,6 +49,7 @@ import (
"tailscale.com/util/httpm"
"tailscale.com/util/mak"
"tailscale.com/util/osdiag"
"tailscale.com/util/rands"
"tailscale.com/version"
)
@@ -98,7 +99,6 @@ var handler = map[string]localAPIHandler{
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
"start": (*Handler).serveStart,
"status": (*Handler).serveStatus,
"stream-serve": (*Handler).serveStreamServe,
"tka/init": (*Handler).serveTKAInit,
"tka/log": (*Handler).serveTKALog,
"tka/modify": (*Handler).serveTKAModify,
@@ -118,12 +118,6 @@ var handler = map[string]localAPIHandler{
"query-feature": (*Handler).serveQueryFeature,
}
func randHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
var (
// The clientmetrics package is stateful, but we want to expose a simple
// imperative API to local clients, so we need to keep track of
@@ -318,7 +312,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
defer h.b.TryFlushLogs() // kick off upload after bugreport's done logging
logMarker := func() string {
return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format("20060102150405Z"), randHex(8))
return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format("20060102150405Z"), rands.HexString(16))
}
if envknob.NoLogsNoSupport() {
logMarker = func() string { return "BUG-NO-LOGS-NO-SUPPORT-this-node-has-had-its-logging-disabled" }
@@ -563,6 +557,13 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
err = h.b.DebugBreakTCPConns()
case "break-derp-conns":
err = h.b.DebugBreakDERPConns()
case "control-knobs":
k := h.b.ControlKnobs()
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(k.AsDebugJSON())
if err == nil {
return
}
case "":
err = fmt.Errorf("missing parameter 'action'")
default:
@@ -702,7 +703,7 @@ func (h *Handler) serveDebugPortmap(w http.ResponseWriter, r *http.Request) {
done := make(chan bool, 1)
var c *portmapper.Client
c = portmapper.NewClient(logger.WithPrefix(logf, "portmapper: "), h.netMon, debugKnobs, func() {
c = portmapper.NewClient(logger.WithPrefix(logf, "portmapper: "), h.netMon, debugKnobs, h.b.ControlKnobs(), func() {
logf("portmapping changed.")
logf("have mapping: %v", c.HaveMapping())
@@ -838,9 +839,17 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, "serve config denied", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
config := h.b.ServeConfig()
json.NewEncoder(w).Encode(config)
bts, err := json.Marshal(config)
if err != nil {
http.Error(w, "error encoding config: "+err.Error(), http.StatusInternalServerError)
return
}
sum := sha256.Sum256(bts)
etag := hex.EncodeToString(sum[:])
w.Header().Set("Etag", etag)
w.Header().Set("Content-Type", "application/json")
w.Write(bts)
case "POST":
if !h.PermitWrite {
http.Error(w, "serve config denied", http.StatusForbidden)
@@ -851,7 +860,12 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
writeErrorJSON(w, fmt.Errorf("decoding config: %w", err))
return
}
if err := h.b.SetServeConfig(configIn); err != nil {
etag := r.Header.Get("If-Match")
if err := h.b.SetServeConfig(configIn, etag); err != nil {
if errors.Is(err, ipnlocal.ErrETagMismatch) {
http.Error(w, err.Error(), http.StatusPreconditionFailed)
return
}
writeErrorJSON(w, fmt.Errorf("updating config: %w", err))
return
}
@@ -861,31 +875,6 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
}
}
// serveStreamServe handles foreground serve and funnel streams. This is
// currently in development per https://github.com/tailscale/tailscale/issues/8489
func (h *Handler) serveStreamServe(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
// Write permission required because we modify the ServeConfig.
http.Error(w, "serve stream denied", http.StatusForbidden)
return
}
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
var req ipn.ServeStreamRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, fmt.Errorf("decoding HostPort: %w", err))
return
}
w.Header().Set("Content-Type", "application/json")
if err := h.b.StreamServe(r.Context(), w, req); err != nil {
writeErrorJSON(w, fmt.Errorf("streaming serve: %w", err))
return
}
w.WriteHeader(http.StatusOK)
}
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "IP forwarding check access denied", http.StatusForbidden)
@@ -1056,7 +1045,7 @@ func (h *Handler) serveLogout(w http.ResponseWriter, r *http.Request) {
http.Error(w, "want POST", 400)
return
}
err := h.b.LogoutSync(r.Context())
err := h.b.Logout(r.Context())
if err == nil {
w.WriteHeader(http.StatusNoContent)
return

View File

@@ -196,6 +196,10 @@ type Prefs struct {
// and CLI.
ProfileName string `json:",omitempty"`
// AutoUpdate sets the auto-update preferences for the node agent. See
// AutoUpdatePrefs docs for more details.
AutoUpdate AutoUpdatePrefs
// The Persist field is named 'Config' in the file for backward
// compatibility with earlier versions.
// TODO(apenwarr): We should move this out of here, it's not a pref.
@@ -204,6 +208,18 @@ type Prefs struct {
Persist *persist.Persist `json:"Config"`
}
// AutoUpdatePrefs are the auto update settings for the node agent.
type AutoUpdatePrefs struct {
// Check specifies whether background checks for updates are enabled. When
// enabled, tailscaled will periodically check for available updates and
// notify the user about them.
Check bool
// Apply specifies whether background auto-updates are enabled. When
// enabled, tailscaled will apply available updates in the background.
// Check must also be set when Apply is set.
Apply bool
}
// MaskedPrefs is a Prefs with an associated bitmask of which fields are set.
type MaskedPrefs struct {
Prefs
@@ -229,6 +245,7 @@ type MaskedPrefs struct {
NetfilterModeSet bool `json:",omitempty"`
OperatorUserSet bool `json:",omitempty"`
ProfileNameSet bool `json:",omitempty"`
AutoUpdateSet bool `json:",omitempty"`
}
// ApplyEdits mutates p, assigning fields from m.Prefs for each MaskedPrefs
@@ -284,6 +301,12 @@ func (m *MaskedPrefs) Pretty() string {
if v.Type().Elem().Kind() == reflect.String {
return "%s=%q"
}
case reflect.Struct:
return "%s=%+v"
case reflect.Pointer:
if v.Type().Elem().Kind() == reflect.Struct {
return "%s=%+v"
}
}
return "%s=%v"
}
@@ -360,6 +383,7 @@ func (p *Prefs) pretty(goos string) string {
if p.OperatorUser != "" {
fmt.Fprintf(&sb, "op=%q ", p.OperatorUser)
}
sb.WriteString(p.AutoUpdate.Pretty())
if p.Persist != nil {
sb.WriteString(p.Persist.Pretty())
} else {
@@ -414,7 +438,18 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
compareIPNets(p.AdvertiseRoutes, p2.AdvertiseRoutes) &&
compareStrings(p.AdvertiseTags, p2.AdvertiseTags) &&
p.Persist.Equals(p2.Persist) &&
p.ProfileName == p2.ProfileName
p.ProfileName == p2.ProfileName &&
p.AutoUpdate == p2.AutoUpdate
}
func (au AutoUpdatePrefs) Pretty() string {
if au.Apply {
return "update=on "
}
if au.Check {
return "update=check "
}
return "update=off "
}
func compareIPNets(a, b []netip.Prefix) bool {
@@ -459,6 +494,10 @@ func NewPrefs() *Prefs {
CorpDNS: true,
WantRunning: false,
NetfilterMode: preftype.NetfilterOn,
AutoUpdate: AutoUpdatePrefs{
Check: true,
Apply: false,
},
}
}

View File

@@ -56,6 +56,7 @@ func TestPrefsEqual(t *testing.T) {
"NetfilterMode",
"OperatorUser",
"ProfileName",
"AutoUpdate",
"Persist",
}
if have := fieldsOf(reflect.TypeOf(Prefs{})); !reflect.DeepEqual(have, prefsHandles) {
@@ -288,6 +289,21 @@ func TestPrefsEqual(t *testing.T) {
&Prefs{ProfileName: "home"},
false,
},
{
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}},
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: false, Apply: false}},
false,
},
{
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: true}},
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}},
false,
},
{
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}},
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}},
true,
},
}
for i, tt := range tests {
got := tt.a.Equals(tt.b)
@@ -372,22 +388,22 @@ func TestPrefsPretty(t *testing.T) {
{
Prefs{},
"linux",
"Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist=nil}",
"Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}",
},
{
Prefs{},
"windows",
"Prefs{ra=false mesh=false dns=false want=false Persist=nil}",
"Prefs{ra=false mesh=false dns=false want=false update=off Persist=nil}",
},
{
Prefs{ShieldsUp: true},
"windows",
"Prefs{ra=false mesh=false dns=false want=false shields=true Persist=nil}",
"Prefs{ra=false mesh=false dns=false want=false shields=true update=off Persist=nil}",
},
{
Prefs{AllowSingleHosts: true},
"windows",
"Prefs{ra=false dns=false want=false Persist=nil}",
"Prefs{ra=false dns=false want=false update=off Persist=nil}",
},
{
Prefs{
@@ -395,7 +411,7 @@ func TestPrefsPretty(t *testing.T) {
AllowSingleHosts: true,
},
"windows",
"Prefs{ra=false dns=false want=false notepad=true Persist=nil}",
"Prefs{ra=false dns=false want=false notepad=true update=off Persist=nil}",
},
{
Prefs{
@@ -404,7 +420,7 @@ func TestPrefsPretty(t *testing.T) {
ForceDaemon: true, // server mode
},
"windows",
"Prefs{ra=false dns=false want=true server=true Persist=nil}",
"Prefs{ra=false dns=false want=true server=true update=off Persist=nil}",
},
{
Prefs{
@@ -414,14 +430,14 @@ func TestPrefsPretty(t *testing.T) {
AdvertiseTags: []string{"tag:foo", "tag:bar"},
},
"darwin",
`Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" Persist=nil}`,
`Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" update=off Persist=nil}`,
},
{
Prefs{
Persist: &persist.Persist{},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist{lm=, o=, n= u=""}}`,
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n= u=""}}`,
},
{
Prefs{
@@ -430,21 +446,21 @@ func TestPrefsPretty(t *testing.T) {
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist{lm=, o=, n=[B1VKl] u=""}}`,
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n=[B1VKl] u=""}}`,
},
{
Prefs{
ExitNodeIP: netip.MustParseAddr("1.2.3.4"),
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off Persist=nil}`,
`Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
ExitNodeID: tailcfg.StableNodeID("myNodeABC"),
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off Persist=nil}`,
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
@@ -452,21 +468,41 @@ func TestPrefsPretty(t *testing.T) {
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off Persist=nil}`,
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist=nil}`,
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
},
{
Prefs{
Hostname: "foo",
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" Persist=nil}`,
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" update=off Persist=nil}`,
},
{
Prefs{
AutoUpdate: AutoUpdatePrefs{
Check: true,
Apply: false,
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=check Persist=nil}`,
},
{
Prefs{
AutoUpdate: AutoUpdatePrefs{
Check: true,
Apply: true,
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=on Persist=nil}`,
},
}
for i, tt := range tests {

View File

@@ -12,7 +12,6 @@ import (
"slices"
"strconv"
"strings"
"time"
"tailscale.com/tailcfg"
)
@@ -37,6 +36,14 @@ type ServeConfig struct {
// AllowFunnel is the set of SNI:port values for which funnel
// traffic is allowed, from trusted ingress peers.
AllowFunnel map[HostPort]bool `json:",omitempty"`
// Foreground is a map of an IPN Bus session ID to an alternate foreground
// serve config that's valid for the life of that WatchIPNBus session ID.
// This. This allows the config to specify ephemeral configs that are
// used in the CLI's foreground mode to ensure ungraceful shutdowns
// of either the client or the LocalBackend does not expose ports
// that users are not aware of.
Foreground map[string]*ServeConfig `json:",omitempty"`
}
// HostPort is an SNI name and port number, joined by a colon.
@@ -78,42 +85,6 @@ type FunnelConn struct {
Src netip.AddrPort
}
// ServeStreamRequest defines the JSON request body
// for the serve stream endpoint
type ServeStreamRequest struct {
// HostPort is the DNS and port of the tailscale
// URL.
HostPort HostPort `json:",omitempty"`
// Source is the user's serve source
// as defined in the `tailscale serve`
// command such as http://127.0.0.1:3000
Source string `json:",omitempty"`
// MountPoint is the path prefix for
// the given HostPort.
MountPoint string `json:",omitempty"`
}
// FunnelRequestLog is the JSON type written out to io.Writers
// watching funnel connections via ipnlocal.StreamServe.
//
// This structure is in development and subject to change.
type FunnelRequestLog struct {
Time time.Time `json:",omitempty"` // time of request forwarding
// SrcAddr is the address that initiated the Funnel request.
SrcAddr netip.AddrPort `json:",omitempty"`
// The following fields are only populated if the connection
// initiated from another node on the client's tailnet.
NodeName string `json:",omitempty"` // src node MagicDNS name
NodeTags []string `json:",omitempty"` // src node tags
UserLoginName string `json:",omitempty"` // src node's owner login (if not tagged)
UserDisplayName string `json:",omitempty"` // src node's owner name (if not tagged)
}
// WebServerConfig describes a web server's configuration.
type WebServerConfig struct {
Handlers map[string]*HTTPHandler // mountPoint => handler
@@ -328,3 +299,102 @@ func CheckFunnelPort(wantedPort uint16, nodeAttrs []string) error {
}
return deny(portsStr)
}
// RangeOverTCPs ranges over both background and foreground TCPs.
// If the returned bool from the given f is false, then this function stops
// iterating immediately and does not check other foreground configs.
func (v ServeConfigView) RangeOverTCPs(f func(port uint16, _ TCPPortHandlerView) bool) {
parentCont := true
v.TCP().Range(func(k uint16, v TCPPortHandlerView) (cont bool) {
parentCont = f(k, v)
return parentCont
})
v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) {
if !parentCont {
return false
}
v.TCP().Range(func(k uint16, v TCPPortHandlerView) (cont bool) {
parentCont = f(k, v)
return parentCont
})
return parentCont
})
}
// RangeOverWebs ranges over both background and foreground Webs.
// If the returned bool from the given f is false, then this function stops
// iterating immediately and does not check other foreground configs.
func (v ServeConfigView) RangeOverWebs(f func(_ HostPort, conf WebServerConfigView) bool) {
parentCont := true
v.Web().Range(func(k HostPort, v WebServerConfigView) (cont bool) {
parentCont = f(k, v)
return parentCont
})
v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) {
if !parentCont {
return false
}
v.Web().Range(func(k HostPort, v WebServerConfigView) (cont bool) {
parentCont = f(k, v)
return parentCont
})
return parentCont
})
}
// FindTCP returns the first TCP that matches with the given port. It
// prefers a foreground match first followed by a background search if none
// existed.
func (v ServeConfigView) FindTCP(port uint16) (res TCPPortHandlerView, ok bool) {
v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) {
res, ok = v.TCP().GetOk(port)
return !ok
})
if ok {
return res, ok
}
return v.TCP().GetOk(port)
}
// FindWeb returns the first Web that matches with the given HostPort. It
// prefers a foreground match first followed by a background search if none
// existed.
func (v ServeConfigView) FindWeb(hp HostPort) (res WebServerConfigView, ok bool) {
v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) {
res, ok = v.Web().GetOk(hp)
return !ok
})
if ok {
return res, ok
}
return v.Web().GetOk(hp)
}
// HasAllowFunnel returns whether this config has at least one AllowFunnel
// set in the background or foreground configs.
func (v ServeConfigView) HasAllowFunnel() bool {
return v.AllowFunnel().Len() > 0 || func() bool {
var exists bool
v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) {
exists = v.AllowFunnel().Len() > 0
return !exists
})
return exists
}()
}
// FindFunnel reports whether target exists in in either the background AllowFunnel
// or any of the foreground configs.
func (v ServeConfigView) HasFunnelForTarget(target HostPort) bool {
if v.AllowFunnel().Get(target) {
return true
}
var exists bool
v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) {
if exists = v.AllowFunnel().Get(target); exists {
return false
}
return true
})
return exists
}

View File

@@ -19,6 +19,7 @@ import (
// Store is an ipn.StateStore that uses a Kubernetes Secret for persistence.
type Store struct {
client *kube.Client
canPatch bool
secretName string
}
@@ -28,8 +29,13 @@ func New(_ logger.Logf, secretName string) (*Store, error) {
if err != nil {
return nil, err
}
canPatch, err := c.CheckSecretPermissions(context.Background(), secretName)
if err != nil {
return nil, err
}
return &Store{
client: c,
canPatch: canPatch,
secretName: secretName,
}, nil
}
@@ -93,6 +99,19 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
}
return err
}
if s.canPatch {
m := []kube.JSONPatch{
{
Op: "add",
Path: "/data/" + sanitizeKey(id),
Value: bs,
},
}
if err := s.client.JSONPatchSecret(ctx, s.secretName, m); err != nil {
return err
}
return nil
}
secret.Data[sanitizeKey(id)] = bs
if err := s.client.UpdateSecret(ctx, secret); err != nil {
return err

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