Compare commits

...

72 Commits

Author SHA1 Message Date
Tyler Smalley
b78b245704 VERSION.txt: this is v1.54.1
Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-11-30 10:17:35 -08:00
Tom DNetto
b709a723ba ipn/ipnlocal: update hostinfo when app connector state is toggled
Updates: https://github.com/tailscale/corp/issues/16041
Signed-off-by: Tom DNetto <tom@tailscale.com>
(cherry picked from commit 8a660a6513)
2023-11-29 14:18:53 -08:00
Denton Gentry
864484b758 Revert "ipn/ipnlocal,cmd/tailscale: persist tailnet name in user profile"
This reverts commit 7acf78116d.

We'll deliver this in 1.56.0.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-11-29 14:18:01 -08:00
Marwan Sulaiman
7acf78116d ipn/ipnlocal,cmd/tailscale: persist tailnet name in user profile
This PR starts to persist the NetMap tailnet name in SetPrefs so that tailscaled
clients can use this value to disambiguate fast user switching from one tailnet
to another that are under the same exact login. We will also try to backfill
this information during backend starts and profile switches so that users don't
have to re-authenticate their profile. The first client to use this new
information is the CLI in 'tailscale switch -list' which now uses text/tabwriter
to display the ID, Tailnet, and Account. Since account names are ambiguous, we
allow the user to pass 'tailscale switch ID' to specify the exact tailnet they
want to switch to.

Updates #9286

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-11-29 12:11:53 -08:00
Denton Gentry
c82fd1256b VERSION.txt: this is v1.54.0
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-11-15 09:52:33 -08:00
James Tucker
e866ee9268 types/appctype: correct app-connector cap name in documentation
Updates #cleanup

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-15 09:47:24 -08:00
Sonia Appasamy
bb31912ea5 cmd/cli: remove --webclient flag from up
Causing issues building a stable release. Getting rid of the flag
for now because it was only available in unstable, can still be
turned on through localapi.

A #cleanup

Co-authored-by: Will Norris <will@tailscale.com>
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-15 12:47:08 -05:00
Will Norris
1cb8d2ffdd ipn/ipnlocal: only call serve handler if non-nil
return early if handler is nil. Go ahead and return the error from
handler, though in this case the caller isn't doing anything with it
(which has always been the case).

Updates #10177
Updates #10251

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-15 08:28:03 -08:00
Andrea Barisani
05d4210dbe adjust build tags for tamago
Signed-off-by: Andrea Barisani <andrea@inversepath.com>
2023-11-15 06:33:02 -08:00
Will Norris
b7918341f9 ipn/ipnlocal: call serve handler for local traffic
Tailscale serve maintains a set of listeners so that serve traffic from
the local device can be properly served when running in kernel
networking mode. #10177 refactored that logic so that it could be reused
by the internal web client as well. However, in my refactoring I missed
actually calling the serve handler to handle the traffic.

Updates #10177

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-14 23:19:43 -08:00
Flakes Updater
e3dacb3e5e go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-14 18:00:19 -08:00
Andrew Lytvynov
c3f1bd4c0a clientupdate: fix auto-update on Windows over RDP (#10242)
`winutil.WTSGetActiveConsoleSessionId` only works for physical desktop
logins and does not return the session ID for RDP logins. We need to
`windows.WTSEnumerateSessions` and find the active session.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-14 17:21:03 -08:00
Will Norris
60957e1077 client/web: fix back button on devices with URL prefix
Move Header component inside Router so that links are relative to the
router base URL.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-14 15:30:13 -08:00
Will Norris
fb984c2b71 client/web: server /index.html on 404 requests
In production, the asset handler is receiving requests for pages like
/details, which results in a 404. Instead, if we know the requested file
does not exist, serve the main index page and let wouter route it
appropriately on the frontend.

Updates tailscale/corp/#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-14 15:29:55 -08:00
Will Norris
74947ce459 client/web: only trigger check mode if not authed
After logging in, the `?check=now` query string is still present if it
was passed. Reloading the page causes a new check mode to be triggered,
even though the user has an active session. Only trigger the automatic
check mode if the user is not already able to manage the device.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-14 12:27:18 -08:00
Will Norris
79719f05a9 ipn/ipnlocal: remove web client listeners after close
This prevents a panic in some cases where WebClientShutdown is called
multiple times.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-14 12:26:14 -08:00
Sonia Appasamy
7c99a1763b client/web: fix panic on logout
Fix panic due to `CurrentTailnet` being nil.

Fixes tailscale/corp#15791

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-14 14:35:13 -05:00
OSS Updater
063657c65f go.mod: update web-client-prebuilt module
Signed-off-by: OSS Updater <noreply+oss-updater@tailscale.com>
2023-11-14 09:47:56 -08:00
Will Norris
7399e56acd .github: add action for updating web-client-prebuilt module
Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-14 09:30:08 -08:00
Andrew Lytvynov
955e2fcbfb ipn/ipnlocal: run "tailscale update" via systemd-run on Linux (#10229)
When we run tailscled under systemd, restarting the unit kills all child
processes, including "tailscale update". And during update, the package
manager will restart the tailscaled unit. Specifically on Debian-based
distros, interrupting `apt-get install` can get the system into a wedged
state which requires the user to manually run `dpkg --configure` to
recover.

To avoid all this, use `systemd-run` where available to run the
`tailscale update` process. This launches it in a separate temporary
unit and doesn't kill it when parent unit is restarted.

Also, detect when `apt-get install` complains about aborted update and
try to restore the system by running `dpkg --configure tailscale`. This
could help if the system unexpectedly shuts down during our auto-update.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-13 16:41:21 -08:00
Jordan Whited
c99488ea19 wgengine/magicsock: fix typo in endpoint.sendDiscoPing() docs (#10232)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-11-13 13:56:26 -08:00
Tom DNetto
90a0aafdca cmd/tailscale: warn if app-connector is enabled without ip forwarding
Fixes: ENG-2446
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-11-13 13:35:35 -08:00
Brad Fitzpatrick
1825d2337b ipn/ipnlocal: respect ExitNodeAllowLANAccess on iOS (#10230)
Updates tailscale/corp#15783

Change-Id: I1082fbfff61a241ebd3b8275be0f45e329b67561

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-13 13:18:30 -08:00
Sonia Appasamy
c9bfb7c683 client/web: add Tailscale SSH view
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-13 15:49:45 -05:00
Brad Fitzpatrick
103c00a175 ipn/ipnlocal: clean up c2n handling's big switch, add a mux table
Updates #cleanup

Change-Id: I29ec03db91e7831a3a66a63dcf6ff8e3f72ab045
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-13 11:57:56 -08:00
Tom DNetto
ce46d92ed2 go.{mod,sum}: update inet.af/tcpproxy to fix flaking test
```
[nix-shell:~/tailscale]$ ./tool/go test ./cmd/sniproxy --failfast --count 80
ok  	tailscale.com/cmd/sniproxy	127.085s
[nix-shell:~/tailscale]$ ./tool/go test ./cmd/sniproxy --failfast --count 80
ok  	tailscale.com/cmd/sniproxy	127.030s
```

Fixes: #10056
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-11-13 11:48:45 -08:00
Joe Tsai
975c5f7684 taildrop: lazily perform full deletion scan after first taildrop use (#10137)
Simply reading the taildrop directory can pop up security dialogs
on platforms like macOS. Avoid this by only performing garbage collection
of partial and deleted files after the first received taildrop file,
which would have prompted the security dialog window.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-11-13 12:20:28 -06:00
Jordan Whited
e848736927 control/controlknobs,wgengine/magicsock: implement SilentDisco toggle (#10195)
This change exposes SilentDisco as a control knob, and plumbs it down to
magicsock.endpoint. No changes are being made to magicsock.endpoint
disco behavior, yet.

Updates #540

Signed-off-by: Jordan Whited <jordan@tailscale.com>
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-13 10:05:04 -08:00
Kristoffer Dalby
fe7f7bff4f posture: ignore not found serial errors
Updates #5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-11-13 18:22:08 +01:00
Sonia Appasamy
86c8ab7502 client/web: add readonly/manage toggle
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-10 15:01:34 -05:00
James Tucker
c54d680682 ipn,tailconfig: clean up unreleased and removed app connector service
This was never released, and is replaced by HostInfo.AppConnector.

Updates tailscale/corp#15437
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-09 22:36:52 -08:00
James Tucker
0b6636295e tailcfg,ipn/ipnlocal: add hostinfo field to replace service entry
Updates tailscale/corp#15437
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-09 20:50:55 -08:00
Andrew Lytvynov
1f4a38ed49 clientupdate: add support for QNAP (#10179)
Use the `qpkg_cli` to check for updates and install them. There are a
couple special things about this compare to other updaters:
* qpkg_cli can tell you when upgrade is available, but not what the
  version is
* qpkg_cli --add Tailscale works for new installs, upgrades and
  reinstalling existing version; even reinstall of existing version
  takes a while

Updates #10178

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-09 17:46:16 -08:00
James Tucker
45be37cb01 ipn/ipnlocal: ensure that hostinfo is updated on app connector preference changes
Some conditional paths may otherwise skip the hostinfo update, so kick
it off asynchronously as other code paths do.

Updates tailscale/corp#15437
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-09 17:38:57 -08:00
James Tucker
933d201bba ipn/policy: mark AppConnector service as interesting
Updates #15437
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-09 17:16:07 -08:00
James Tucker
1a143963ec appc: prevent duplication of wildcard entries on map updates
Updates #15437
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-09 16:47:42 -08:00
Andrew Lytvynov
6cce5fe001 go.toolchain.rev: bump to Go 1.21.4 (#10189)
Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-09 13:39:56 -08:00
Brad Fitzpatrick
53c4adc982 ssh/tailssh: add envknobs to force override forwarding, sftp, pty
Updates tailscale/corp#15735

Change-Id: Ib1303406be925c3231ce7e0950a173ad12626492
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-09 13:37:54 -08:00
Brad Fitzpatrick
ffabe5fe21 ssh/tailssh: fix sftp metric increment location
We were incrementing the sftp metric on regular sessions
too, not just sftp.

Updates #cleanup

Change-Id: I63027a39cffb3e03397c6e4829b1620c10fa3130
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-09 13:08:07 -08:00
Naman Sood
e57fd9cda4 ipn/{ipnlocal,ipnstate,localapi}: add localapi endpoints for client self-update (#10188)
* ipn/{ipnlocal,ipnstate,localapi}: add localapi endpoints for client self-update

Updates #10187.

Signed-off-by: Naman Sood <mail@nsood.in>

* depaware

Updates #10187.

Signed-off-by: Naman Sood <mail@nsood.in>

* address review feedback

Signed-off-by: Naman Sood <mail@nsood.in>

---------

Signed-off-by: Naman Sood <mail@nsood.in>
2023-11-09 16:00:47 -05:00
Andrew Lytvynov
55cd5c575b ipn/localapi: only perform local-admin check in serveServeConfig (#10163)
On unix systems, the check involves executing sudo, which is slow.
Instead of doing it for every incoming request, move the logic into
localapi serveServeConfig handler and do it as needed.

Updates tailscale/corp#15405

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-09 12:55:46 -08:00
James Tucker
73de6a1a95 appc: add support for matching wildcard domains
The app connector matches a configuration of "*.example.com" to mean any
sub-domain of example.com.

Updates #15437

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-09 12:39:30 -08:00
Jordan Whited
12d5c99b04 client/tailscale,ipn/{ipnlocal,localapi}: check UDP GRO config (#10071)
Updates tailscale/corp#9990

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-11-09 11:34:41 -08:00
Andrew Lytvynov
1fc1077052 ssh/tailssh,util: extract new osuser package from ssh code (#10170)
This package is a wrapper for os/user that handles non-cgo builds,
gokrazy and user shells.

Updates tailscale/corp#15405

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-09 09:49:06 -08:00
Will Norris
09de240934 ipn/ipnlocal: allow connecting to local web client
The local web client has the same characteristic as tailscale serve, in
that it needs a local listener to allow for connections from the local
machine itself when running in kernel networking mode.

This change renames and adapts the existing serveListener to allow it to
be used by the web client as well.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-09 08:49:15 -08:00
Brad Fitzpatrick
d36a0d42aa tsnet: check a bit harder for https in Server.ListenFunnel
This was mostly already fixed already indirectly in earlier
commits but add a last second length check to this slice so
it can't ever OOB.

Fixes #7860

Change-Id: I31ac17fc93b5808deb09ff34e452fe37c87ddf3a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-09 07:51:58 -08:00
Andrew Lytvynov
bff786520e clientupdate,ipn/ipnlocal: fix c2n update on freebsd (#10168)
The c2n part was broken because we were not looking up the tailscale
binary for that GOOS. The rest of the update was failing at the `pkg
upgrade` confirmation prompt. We also need to manually restart
tailscaled after update.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-08 18:56:00 -07:00
Sonia Appasamy
d544e80fc1 client/web: populate device details view
Fills /details page with real values, passed back from the /data
endpoint.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-08 17:59:58 -05:00
Brad Fitzpatrick
d852c616c6 logtail: fix Logger.Write return result
io.Writer says you need to write completely on err=nil. (the result
int should be the same as the input buffer length)

We weren't doing that. We used to, but at some point the verbose
filtering was modifying buf before the final return of len(buf).

We've been getting lucky probably, that callers haven't looked at our
results and turned us into a short write error.

Updates #cleanup
Updates tailscale/corp#15664

Change-Id: I01e765ba35b86b759819e38e0072eceb9d10d75c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-08 11:57:15 -08:00
Tom DNetto
11a20f371a ipn/ipnlocal: fix nil control client panic while updating TKA head
As part of tailnet-lock netmap processing, the LocalBackend mutex
is unlocked so we can potentially make a network call. Its possible
(during shutdown or while the control client is being reset) for
b.cc to become nil before the lock is picked up again.

Fixes: #9554
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-11-08 10:44:25 -08:00
Tom DNetto
3496d62ed3 ipn/ipnlocal: add empty address to the app-connector localNets set
App connectors handle DNS requests for app domains over PeerAPI,
but a safety check verifies the requesting peer has at least permission
to send traffic to 0.0.0.0:53 (or 2000:: for IPv6) before handling the DNS
request. The correct filter rules are synthesized by the coordination server
and sent down, but the address needs to be part of the 'local net' for the
filter package to even bother checking the filter rules, so we set them here.
See: https://github.com/tailscale/corp/issues/11961 for more information.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates: ENG-2405
2023-11-08 10:44:03 -08:00
Will Norris
fdbe511c41 cmd/tailscale: add -webclient flag to up and set
Initially, only expose this flag on dev and unstable builds.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-08 10:00:58 -08:00
Charlotte Brandhorst-Satzkorn
f937cb6794 tailcfg,ipn,appc: add c2n endpoint for appc domain routes
This change introduces a c2n endpoint that returns a map of domains to a
slice of resolved IP addresses for the domain.

Fixes tailscale/corp#15657

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-11-07 18:12:24 -08:00
Andrew Lytvynov
63062abadc clientupdate: check whether running as root early (#10161)
Check for root early, before we fetch the pkgs index. This avoids
several seconds delay for the command to tell you to sudo.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-07 13:09:30 -08:00
Andrew Lytvynov
9b158db2c6 ipn/localapi: require root or sudo+operator access for SetServeConfig (#10142)
For an operator user, require them to be able to `sudo tailscale` to use
`tailscale serve`. This is similar to the Windows elevated token check.

Updates tailscale/corp#15405

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-07 12:31:33 -08:00
Will Norris
fc2d63bb8c go.mod: updates web-client-prebuilt
Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-07 11:16:37 -08:00
Will Norris
623f669239 client/web: pass URL prefix to frontend
This allows wouter to route URLs properly when running in CGI mode.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-07 11:13:01 -08:00
Sonia Appasamy
0753ad6cf8 client/web: move useNodeData out of App component
Only loading data once auth request has completed.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-07 13:31:05 -05:00
Will Norris
d530153d2f go.mod: bump web-client-prebuilt
Updates #cleanup

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-07 09:29:00 -08:00
Sonia Appasamy
5e095ddc20 client/web: add initial framework for exit node selector
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-07 11:30:22 -05:00
Sonia Appasamy
de2af54ffc client/web: pipe newSession through to readonly view
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-07 11:12:40 -05:00
Sonia Appasamy
d73e923b73 client/web: add device details view
Initial addition of device details view on the frontend. A little
more backend piping work to come to fill all of the detail fields,
for now using placeholders.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-07 10:59:18 -05:00
Will Norris
3e9026efda client/web: show manage button in readonly view
We render the readonly view in two situations:
- the client is in login mode, and the device is connected
- the client is in manage mode, but the user does not yet have a session

If the user is not authenticated, and they are not currently on the
Tailscale IP address, render a "Manage" button that will take them to
the Tailcale IP of the device and immediately start check mode.

Still to do is detecting if they have connectivity to the Tailscale IP,
and disabling the button if not.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-07 07:50:26 -08:00
Thomas Kosiewski
96a80fcce3 Add support for custom DERP port in TLS prober
Updates #10146

Signed-off-by: Thomas Kosiewski <thoma471@googlemail.com>
2023-11-07 12:56:13 +00:00
Charlotte Brandhorst-Satzkorn
839fee9ef4 wgengine/magicsock: handle wireguard only clean up and log messages
This change updates log messaging when cleaning up wireguard only peers.
This change also stops us unnecessarily attempting to clean up disco
pings for wireguard only endpoints.

Updates #7826

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-11-06 16:26:31 -08:00
Sonia Appasamy
3269b36bd0 client/web: fix hotreload proxy
Previously had HMR websocket set to run from a different port
than the http proxy server. This was an old setting carried over
from the corp repo admin panel config. It's messing with hot
reloads when run from the tailscaled web client, as it keeps
causing the full page to refresh each time a connection is made.
Switching back to the default config here fixes things.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-06 16:31:30 -05:00
Sonia Appasamy
942d720a16 cli/web: don't block startup on status req
If the status request to check for the preview node cap fails,
continue with starting up the legacy client.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-06 16:19:16 -05:00
Sonia Appasamy
7df2c5d6b1 client/web: add route management for ui pages
Using wouter, a lightweight React routing library.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-06 15:55:55 -05:00
License Updater
a97ead9ce4 licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-11-06 12:37:18 -08:00
License Updater
aeb5a8b123 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-11-06 12:36:44 -08:00
Sonia Appasamy
f2a4c4fa55 client/web: build out client home page
Hooks up more of the home page UI.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-06 15:35:15 -05:00
Andrew Lytvynov
aba4bd0c62 util/winutil: simplify dropping privileges after use (#10099)
To safely request and drop privileges, runtime.Lock/UnlockOSThread and
windows.Impersonate/RevertToSelf should be called. Add these calls to
winutil.EnableCurrentThreadPrivilege so that callers don't need to worry
about it.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-06 11:37:37 -08:00
91 changed files with 3238 additions and 737 deletions

View File

@@ -0,0 +1,51 @@
name: update-webclient-prebuilt
on:
# manually triggered
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
update-webclient-prebuilt:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Run go get
run: |
GOPROXY=direct ./tool/go get github.com/tailscale/web-client-prebuilt
./tool/go mod tidy
- name: Get access token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
id: generate-token
with:
# TODO(will): this should use the code updater app rather than licensing.
# It has the same permissions, so not a big deal, but still.
app_id: ${{ secrets.LICENSING_APP_ID }}
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
id: pull-request
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: OSS Updater <noreply+oss-updater@tailscale.com>
committer: OSS Updater <noreply+oss-updater@tailscale.com>
branch: actions/update-webclient-prebuilt
commit-message: "go.mod: update web-client-prebuilt module"
title: "go.mod: update web-client-prebuilt module"
body: Triggered by ${{ github.repository }}@${{ github.sha }}
signoff: true
delete-branch: true
reviewers: ${{ github.triggering_actor }}
- name: Summary
if: ${{ steps.pull-request.outputs.pull-request-number }}
run: echo "${{ steps.pull-request.outputs.pull-request-operation}} ${{ steps.pull-request.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY

View File

@@ -1 +1 @@
1.53.0
1.54.1

View File

@@ -19,6 +19,7 @@ import (
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
)
// RouteAdvertiser is an interface that allows the AppConnector to advertise
@@ -47,6 +48,9 @@ type AppConnector struct {
// domains is a map of lower case domain names with no trailing dot, to a
// list of resolved IP addresses.
domains map[string][]netip.Addr
// wildcards is the list of domain strings that match subdomains.
wildcards []string
}
// NewAppConnector creates a new AppConnector.
@@ -59,18 +63,38 @@ func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConn
// UpdateDomains replaces the current set of configured domains with the
// supplied set of domains. Domains must not contain a trailing dot, and should
// be lower case.
// be lower case. If the domain contains a leading '*' label it matches all
// subdomains of a domain.
func (e *AppConnector) UpdateDomains(domains []string) {
e.mu.Lock()
defer e.mu.Unlock()
var old map[string][]netip.Addr
old, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
var oldDomains map[string][]netip.Addr
oldDomains, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
e.wildcards = e.wildcards[:0]
for _, d := range domains {
d = strings.ToLower(d)
e.domains[d] = old[d]
if len(d) == 0 {
continue
}
if strings.HasPrefix(d, "*.") {
e.wildcards = append(e.wildcards, d[2:])
continue
}
e.domains[d] = oldDomains[d]
delete(oldDomains, d)
}
e.logf("handling domains: %v", xmaps.Keys(e.domains))
// Ensure that still-live wildcards addresses are preserved as well.
for d, addrs := range oldDomains {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(d, wc) {
e.domains[d] = addrs
break
}
}
}
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(e.domains), e.wildcards)
}
// Domains returns the currently configured domain list.
@@ -81,6 +105,20 @@ func (e *AppConnector) Domains() views.Slice[string] {
return views.SliceOf(xmaps.Keys(e.domains))
}
// DomainRoutes returns a map of domains to resolved IP
// addresses.
func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
e.mu.Lock()
defer e.mu.Unlock()
drCopy := make(map[string][]netip.Addr)
for k, v := range e.domains {
copy(drCopy[k], v)
}
return drCopy
}
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
// response is being returned over the PeerAPI. The response is parsed and
// matched against the configured domains, if matched the routeAdvertiser is
@@ -120,15 +158,24 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
if len(domain) == 0 {
return
}
if domain[len(domain)-1] == '.' {
domain = domain[:len(domain)-1]
}
domain = strings.TrimSuffix(domain, ".")
domain = strings.ToLower(domain)
e.logf("[v2] observed DNS response for %s", domain)
e.mu.Lock()
addrs, ok := e.domains[domain]
// match wildcard domains
if !ok {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(domain, wc) {
e.domains[domain] = nil
ok = true
break
}
}
}
e.mu.Unlock()
if !ok {
if err := p.SkipAnswer(); err != nil {
return

View File

@@ -67,6 +67,34 @@ func TestObserveDNSResponse(t *testing.T) {
}
}
func TestWildcardDomains(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
a.UpdateDomains([]string{"*.example.com"})
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
if got, want := rc.routes, []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
t.Errorf("routes: got %v; want %v", got, want)
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.UpdateDomains([]string{"*.example.com", "example.com"})
if _, ok := a.domains["foo.example.com"]; !ok {
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
a.UpdateDomains([]string{"*.example.com", "example.com"})
if len(a.wildcards) != 1 {
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
}
}
// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address
func dnsResponse(domain, address string) []byte {
addr := netip.MustParseAddr(address)

View File

@@ -679,6 +679,26 @@ func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
return nil
}
// CheckUDPGROForwarding asks the local Tailscale daemon whether it looks like
// the machine is optimally configured to forward UDP packets as a subnet router
// or exit node.
func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/check-udp-gro-forwarding")
if err != nil {
return err
}
var jres struct {
Warning string
}
if err := json.Unmarshal(body, &jres); err != nil {
return fmt.Errorf("invalid JSON from check-udp-gro-forwarding: %w", err)
}
if jres.Warning != "" {
return errors.New(jres.Warning)
}
return nil
}
// CheckPrefs validates the provided preferences, without making any changes.
//
// The CLI uses this before a Start call to fail fast if the preferences won't

View File

@@ -4,6 +4,7 @@
package web
import (
"io/fs"
"log"
"net/http"
"net/http/httputil"
@@ -22,7 +23,19 @@ func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
cleanup := startDevServer()
return devServerProxy(), cleanup
}
return http.FileServer(http.FS(prebuilt.FS())), nil
fsys := prebuilt.FS()
fileserver := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := fs.Stat(fsys, strings.TrimPrefix(r.URL.Path, "/"))
if os.IsNotExist(err) {
// rewrite request to just fetch /index.html and let
// the frontend router handle it.
r = r.Clone(r.Context())
r.URL.Path = "/"
}
fileserver.ServeHTTP(w, r)
}), nil
}
// startDevServer starts the JS dev server that does on-demand rebuilding
@@ -35,7 +48,7 @@ func startDevServer() (cleanup func()) {
node := filepath.Join(root, "tool", "node")
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
log.Printf("installing JavaScript deps using %s...", yarn)
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
if err != nil {
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)

View File

@@ -8,9 +8,11 @@
},
"private": true,
"dependencies": {
"@radix-ui/react-popover": "^1.0.6",
"classnames": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"wouter": "^2.11.0"
},
"devDependencies": {
"@types/classnames": "^2.2.10",

View File

@@ -20,16 +20,16 @@ import (
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
// are authorized to use the web client.
// If the user is not authorized to use the client, an error is returned.
func authorizeQNAP(r *http.Request) (ar authResponse, err error) {
func authorizeQNAP(r *http.Request) (authorized bool, err error) {
_, resp, err := qnapAuthn(r)
if err != nil {
return ar, err
return false, err
}
if resp.IsAdmin == 0 {
return ar, errors.New("user is not an admin")
return false, errors.New("user is not an admin")
}
return authResponse{OK: true}, nil
return true, nil
}
type qnapAuthResponse struct {

View File

@@ -10,7 +10,7 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8
// (i.e. provide `/data` rather than `api/data`).
export function apiFetch(
endpoint: string,
method: "GET" | "POST",
method: "GET" | "POST" | "PATCH",
body?: any,
params?: Record<string, string>
): Promise<Response> {

View File

@@ -0,0 +1,25 @@
import cx from "classnames"
import React from "react"
import Badge from "src/ui/badge"
/**
* ACLTag handles the display of an ACL tag.
*/
export default function ACLTag({
tag,
className,
}: {
tag: string
className?: string
}) {
return (
<Badge
variant="status"
color="outline"
className={cx("flex text-xs items-center", className)}
>
<span className="font-medium">tag:</span>
<span className="text-gray-500">{tag.replace("tag:", "")}</span>
</Badge>
)
}

View File

@@ -1,72 +1,141 @@
import cx from "classnames"
import React from "react"
import React, { useEffect } from "react"
import LoginToggle from "src/components/login-toggle"
import DeviceDetailsView from "src/components/views/device-details-view"
import HomeView from "src/components/views/home-view"
import LegacyClientView from "src/components/views/legacy-client-view"
import LoginClientView from "src/components/views/login-client-view"
import ReadonlyClientView from "src/components/views/readonly-client-view"
import useAuth, { AuthResponse, SessionsCallbacks } from "src/hooks/auth"
import useNodeData from "src/hooks/node-data"
import ManagementClientView from "./views/management-client-view"
import SSHView from "src/components/views/ssh-view"
import useAuth, { AuthResponse } from "src/hooks/auth"
import useNodeData, { NodeData } from "src/hooks/node-data"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import { Link, Route, Router, Switch, useLocation } from "wouter"
export default function App() {
const { data: auth, loading: loadingAuth, sessions } = useAuth()
const { data: auth, loading: loadingAuth, newSession } = useAuth()
return (
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-14">
{loadingAuth ? (
<main className="min-w-sm max-w-lg mx-auto py-14 px-5">
{loadingAuth || !auth ? (
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
) : (
<WebClient auth={auth} sessions={sessions} />
<WebClient auth={auth} newSession={newSession} />
)}
</div>
</main>
)
}
function WebClient({
auth,
sessions,
newSession,
}: {
auth?: AuthResponse
sessions: SessionsCallbacks
auth: AuthResponse
newSession: () => Promise<void>
}) {
const { data, refreshData, updateNode } = useNodeData()
const { data, refreshData, updateNode, updatePrefs } = useNodeData()
useEffect(() => {
refreshData()
}, [auth, refreshData])
return (
return !data ? (
<div className="text-center py-14">Loading...</div>
) : data.Status === "NeedsLogin" || data.Status === "NoState" ? (
// Client not on a tailnet, render login.
<LoginClientView
data={data}
onLoginClick={() => updateNode({ Reauthenticate: true })}
/>
) : data.DebugMode !== "full" && data.DebugMode !== "login" ? (
// Render legacy client interface.
<>
{!data ? (
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
) : data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
// Client not on a tailnet, render login.
<LoginClientView
data={data}
onLoginClick={() => updateNode({ Reauthenticate: true })}
/>
) : data.DebugMode === "full" && auth?.ok ? (
// Render new client interface in management mode.
<ManagementClientView {...data} />
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
// Render new client interface in readonly mode.
<ReadonlyClientView data={data} auth={auth} sessions={sessions} />
) : (
// Render legacy client interface.
<LegacyClientView
data={data}
refreshData={refreshData}
updateNode={updateNode}
/>
)}
{data && <Footer licensesURL={data.LicensesURL} />}
<LegacyClientView
data={data}
refreshData={refreshData}
updateNode={updateNode}
/>
{/* TODO: add license to new client */}
<Footer licensesURL={data.LicensesURL} />
</>
) : (
// Otherwise render the new web client.
<>
<Router base={data.URLPrefix}>
<Header node={data} auth={auth} newSession={newSession} />
<Switch>
<Route path="/">
<HomeView
readonly={!auth.canManageNode}
node={data}
updateNode={updateNode}
/>
</Route>
<Route path="/details">
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
</Route>
<Route path="/subnets">{/* TODO */}Subnet router</Route>
<Route path="/ssh">
<SSHView
readonly={!auth.canManageNode}
runningSSH={data.RunningSSHServer}
updatePrefs={updatePrefs}
/>
</Route>
<Route path="/serve">{/* TODO */}Share local content</Route>
<Route>
<h2 className="mt-8">Page not found</h2>
</Route>
</Switch>
</Router>
</>
)
}
export function Footer(props: { licensesURL: string; className?: string }) {
function Header({
node,
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => Promise<void>
}) {
const [loc] = useLocation()
return (
<footer
className={cx("container max-w-lg mx-auto text-center", props.className)}
>
<>
<div className="flex justify-between mb-12">
<div className="flex gap-3">
<TailscaleIcon />
<div className="inline text-neutral-800 text-lg font-medium leading-snug">
{node.DomainName}
</div>
</div>
<LoginToggle node={node} auth={auth} newSession={newSession} />
</div>
{loc !== "/" && (
<Link
to="/"
className="text-indigo-500 font-medium leading-snug block mb-[10px]"
>
&larr; Back to {node.DeviceName}
</Link>
)}
</>
)
}
function Footer({
licensesURL,
className,
}: {
licensesURL: string
className?: string
}) {
return (
<footer className={cx("container max-w-lg mx-auto text-center", className)}>
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={props.licensesURL}
href={licensesURL}
>
Open Source Licenses
</a>

View File

@@ -0,0 +1,175 @@
import cx from "classnames"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
import { ReactComponent as Check } from "src/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as Search } from "src/icons/search.svg"
const noExitNode = "None"
const runAsExitNode = "Run as exit node…"
export default function ExitNodeSelector({
className,
node,
updateNode,
disabled,
}: {
className?: string
node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined
disabled?: boolean
}) {
const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState(
node.AdvertiseExitNode ? runAsExitNode : noExitNode
)
useEffect(() => {
setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
}, [node])
const handleSelect = useCallback(
(item: string) => {
setOpen(false)
if (item === selected) {
return // no update
}
const old = selected
setSelected(item)
var update: NodeUpdate = {}
switch (item) {
case noExitNode:
// turn off exit node
update = { AdvertiseExitNode: false }
break
case runAsExitNode:
// turn on exit node
update = { AdvertiseExitNode: true }
break
}
updateNode(update)?.catch(() => setSelected(old))
},
[setOpen, selected, setSelected]
)
// TODO: close on click outside
// TODO(sonia): allow choosing to use another exit node
const [
none, // not using exit nodes
advertising, // advertising as exit node
using, // using another exit node
] = useMemo(
() => [
selected === noExitNode,
selected === runAsExitNode,
selected !== noExitNode && selected !== runAsExitNode,
],
[selected]
)
return (
<>
<div
className={cx(
"p-1.5 rounded-md border flex items-stretch gap-1.5",
{
"border-gray-200": none,
"bg-amber-600 border-amber-600": advertising,
"bg-indigo-500 border-indigo-500": using,
},
className
)}
>
<button
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
"bg-white hover:bg-stone-100": none,
"bg-amber-600 hover:bg-orange-400": advertising,
"bg-indigo-500 hover:bg-indigo-400": using,
"cursor-not-allowed": disabled,
})}
onClick={() => setOpen(!open)}
disabled={disabled}
>
<p
className={cx(
"text-neutral-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
{ "bg-opacity-70 text-white": advertising || using }
)}
>
Exit node
</p>
<div className="flex items-center">
<p
className={cx("text-neutral-800", {
"text-white": advertising || using,
})}
>
{selected === runAsExitNode ? "Running as exit node" : "None"}
</p>
<ChevronDown
className={cx("ml-1", {
"stroke-neutral-800": none,
"stroke-white": advertising || using,
})}
/>
</div>
</button>
{(advertising || using) && (
<button
className={cx("px-3 py-2 rounded-sm text-white cursor-pointer", {
"bg-orange-400": advertising,
"bg-indigo-400": using,
})}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleSelect(noExitNode)
}}
>
Disable
</button>
)}
</div>
{open && (
<div className="absolute ml-1.5 -mt-3 w-full max-w-md py-1 bg-white rounded-lg shadow">
<div className="w-full px-4 py-2 flex items-center gap-2.5">
<Search />
<input
className="flex-1 leading-snug"
placeholder="Search exit nodes…"
/>
</div>
<DropdownSection
items={[noExitNode, runAsExitNode]}
selected={selected}
onSelect={handleSelect}
/>
</div>
)}
</>
)
}
function DropdownSection({
items,
selected,
onSelect,
}: {
items: string[]
selected?: string
onSelect: (item: string) => void
}) {
return (
<div className="w-full mt-1 pt-1 border-t border-gray-200">
{items.map((v) => (
<button
key={v}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
onClick={() => onSelect(v)}
>
<div className="leading-snug">{v}</div>
{selected == v && <Check />}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,149 @@
import cx from "classnames"
import React, { useCallback, useState } from "react"
import { AuthResponse, AuthType } from "src/hooks/auth"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as Eye } from "src/icons/eye.svg"
import { ReactComponent as User } from "src/icons/user.svg"
import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic"
export default function LoginToggle({
node,
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => Promise<void>
}) {
const [open, setOpen] = useState<boolean>(false)
return (
<Popover
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
content={
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
}
side="bottom"
align="end"
open={open}
onOpenChange={setOpen}
asChild
>
{!auth.canManageNode ? (
<button
className={cx(
"pl-3 py-1 bg-zinc-800 rounded-full flex justify-start items-center",
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
)}
onClick={() => setOpen(!open)}
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
) : (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full items-center inline-flex",
{
"bg-transparent": !open,
"bg-neutral-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic
size="medium"
url={auth.viewerIdentity?.profilePicUrl}
/>
</button>
</div>
)}
</Popover>
)
}
function LoginPopoverContent({
node,
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => Promise<void>
}) {
const handleSignInClick = useCallback(() => {
if (auth.viewerIdentity) {
newSession()
} else {
// Must be connected over Tailscale to log in.
// If not already connected, reroute to the Tailscale IP
// before sending user through check mode.
window.location.href = `http://${node.IP}:5252/?check=now`
}
}, [node.IP, auth.viewerIdentity, newSession])
return (
<>
<div className="text-black text-sm font-medium leading-tight">
{!auth.canManageNode ? "Viewing" : "Managing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div>
{!auth.canManageNode &&
(!auth.viewerIdentity || auth.authNeeded == AuthType.tailscale ? (
<>
<p className="text-neutral-500 text-xs">
{auth.viewerIdentity ? (
<>
To make changes, sign in to confirm your identity. This extra
step helps us keep your device secure.
</>
) : (
<>
You can see most of this device's details. To make changes,
you need to sign in.
</>
)}
</p>
<button
className={cx(
"w-full px-3 py-2 bg-indigo-500 rounded shadow text-center text-white text-sm font-medium mt-2",
{ "mb-2": auth.viewerIdentity }
)}
onClick={handleSignInClick}
>
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
</button>
</>
) : (
<p className="text-neutral-500 text-xs">
You dont have permission to make changes to this device, but you
can view most of its details.
</p>
))}
{auth.viewerIdentity && (
<>
<hr />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-neutral-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
)}
</>
)
}

View File

@@ -0,0 +1,128 @@
import cx from "classnames"
import React from "react"
import { apiFetch } from "src/api"
import { NodeData } from "src/hooks/node-data"
import { useLocation } from "wouter"
import ACLTag from "../acl-tag"
export default function DeviceDetailsView({
readonly,
node,
}: {
readonly: boolean
node: NodeData
}) {
const [, setLocation] = useLocation()
return (
<>
<h1 className="mb-10">Device details</h1>
<div className="flex flex-col gap-4">
<div className="card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1>{node.DeviceName}</h1>
<div
className={cx("w-2.5 h-2.5 rounded-full", {
"bg-emerald-500": node.Status === "Running",
"bg-gray-300": node.Status !== "Running",
})}
/>
</div>
<button
className={cx(
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium",
{ "cursor-not-allowed": readonly }
)}
onClick={() =>
apiFetch("/local/v0/logout", "POST")
.then(() => setLocation("/"))
.catch((err) => alert("Logout failed: " + err.message))
}
disabled={readonly}
>
Disconnect
</button>
</div>
</div>
<div className="card">
<h2 className="mb-2">General</h2>
<table>
<tbody>
<tr className="flex">
<td>Managed by</td>
<td className="flex gap-1 flex-wrap">
{node.IsTagged
? node.Tags.map((t) => <ACLTag key={t} tag={t} />)
: node.Profile.DisplayName}
</td>
</tr>
<tr>
<td>Machine name</td>
<td>{node.DeviceName}</td>
</tr>
<tr>
<td>OS</td>
<td>{node.OS}</td>
</tr>
<tr>
<td>ID</td>
<td>{node.ID}</td>
</tr>
<tr>
<td>Tailscale version</td>
<td>{node.IPNVersion}</td>
</tr>
<tr>
<td>Key expiry</td>
<td>
{node.KeyExpired
? "Expired"
: // TODO: present as relative expiry (e.g. "5 months from now")
new Date(node.KeyExpiry).toLocaleString()}
</td>
</tr>
</tbody>
</table>
</div>
<div className="card">
<h2 className="mb-2">Addresses</h2>
<table>
<tbody>
<tr>
<td>Tailscale IPv4</td>
<td>{node.IP}</td>
</tr>
<tr>
<td>Tailscale IPv6</td>
<td>{node.IPv6}</td>
</tr>
<tr>
<td>Short domain</td>
<td>{node.DeviceName}</td>
</tr>
<tr>
<td>Full domain</td>
<td>
{node.DeviceName}.{node.TailnetName}
</td>
</tr>
</tbody>
</table>
</div>
<p className="text-neutral-500 text-sm leading-tight text-center">
Want even more details? Visit{" "}
<a
// TODO: pipe control serve url from backend
href="https://login.tailscale.com/admin"
target="_blank"
className="text-indigo-700 text-sm"
>
this devices page
</a>{" "}
in the admin console.
</p>
</div>
</>
)
}

View File

@@ -0,0 +1,123 @@
import cx from "classnames"
import React from "react"
import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { Link } from "wouter"
export default function HomeView({
readonly,
node,
updateNode,
}: {
readonly: boolean
node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined
}) {
return (
<div className="mb-12 w-full">
<h2 className="mb-3">This device</h2>
<div className="-mx-5 card mb-9">
<div className="flex justify-between items-center text-lg mb-5">
<div className="flex items-center">
<ConnectedDeviceIcon />
<div className="ml-3">
<h1>{node.DeviceName}</h1>
{/* TODO(sonia): display actual status */}
<p className="text-neutral-500 text-sm">Connected</p>
</div>
</div>
<p className="text-neutral-800 text-lg leading-[25.20px]">
{node.IP}
</p>
</div>
<ExitNodeSelector
className="mb-5"
node={node}
updateNode={updateNode}
disabled={readonly}
/>
<Link
className="text-indigo-500 font-medium leading-snug"
to="/details"
>
View device details &rarr;
</Link>
</div>
<h2 className="mb-3">Settings</h2>
<SettingsCard
link="/subnets"
className="mb-3"
title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them."
/>
<SettingsCard
link="/ssh"
className="mb-3"
title="Tailscale SSH server"
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
badge={
node.RunningSSHServer
? {
text: "Running",
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
}
: undefined
}
/>
<SettingsCard
link="/serve"
title="Share local content"
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
/>
</div>
)
}
function SettingsCard({
title,
link,
body,
badge,
className,
}: {
title: string
link: string
body: string
badge?: {
text: string
icon?: JSX.Element
}
className?: string
}) {
return (
<Link
to={link}
className={cx(
"-mx-5 card flex justify-between items-center cursor-pointer",
className
)}
>
<div>
<div className="flex gap-2">
<p className="text-neutral-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-neutral-500 text-xs font-medium">
{badge.text}
</div>
</div>
)}
</div>
<p className="text-neutral-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div>
</Link>
)
}

View File

@@ -1,35 +0,0 @@
import React from "react"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import ProfilePic from "src/ui/profile-pic"
export default function ManagementClientView(props: NodeData) {
return (
<div className="px-5 mb-12">
<div className="flex justify-between mb-12">
<TailscaleIcon />
<div className="flex">
<p className="mr-2">{props.Profile.LoginName}</p>
{/* TODO(sonia): support tagged node profile view more eloquently */}
<ProfilePic url={props.Profile.ProfilePicURL} />
</div>
</div>
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
<div className="flex justify-between items-center text-lg">
<div className="flex items-center">
<ConnectedDeviceIcon />
<p className="font-medium ml-3">{props.DeviceName}</p>
</div>
<p className="tracking-widest">{props.IP}</p>
</div>
</div>
<p className="text-gray-500 pt-2">
Tailscale is up and running. You can connect to this device from devices
in your tailnet by using its name or IP address.
</p>
<button className="button button-blue mt-6">Advertise exit node</button>
</div>
)
}

View File

@@ -1,71 +0,0 @@
import React from "react"
import { AuthResponse, AuthType, SessionsCallbacks } from "src/hooks/auth"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
import ProfilePic from "src/ui/profile-pic"
/**
* ReadonlyClientView is rendered when the web interface is either
*
* 1. being viewed by a user not allowed to manage the node
* (e.g. user does not own the node)
*
* 2. or the user is allowed to manage the node but does not
* yet have a valid browser session.
*/
export default function ReadonlyClientView({
data,
auth,
sessions,
}: {
data: NodeData
auth?: AuthResponse
sessions: SessionsCallbacks
}) {
return (
<>
<div className="pb-52 mx-auto">
<TailscaleLogo />
</div>
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
<div className="flex gap-2.5">
<ProfilePic url={data.Profile.ProfilePicURL} />
<div className="font-medium">
<div className="text-neutral-500 text-xs uppercase tracking-wide">
Managed by
</div>
<div className="text-neutral-800 text-sm leading-tight">
{/* TODO(sonia): support tagged node profile view more eloquently */}
{data.Profile.LoginName}
</div>
</div>
</div>
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
<div className="flex gap-3">
<ConnectedDeviceIcon />
<div className="text-neutral-800">
<div className="text-lg font-medium leading-[25.20px]">
{data.DeviceName}
</div>
<div className="text-sm leading-tight">{data.IP}</div>
</div>
</div>
{auth?.authNeeded == AuthType.tailscale && (
<button
className="button button-blue ml-6"
onClick={() => {
sessions
.new()
.then((url) => window.open(url, "_blank"))
.then(() => sessions.wait())
}}
>
Access
</button>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,51 @@
import React from "react"
import { PrefsUpdate } from "src/hooks/node-data"
import Toggle from "src/ui/toggle"
export default function SSHView({
readonly,
runningSSH,
updatePrefs,
}: {
readonly: boolean
runningSSH: boolean
updatePrefs: (p: PrefsUpdate) => Promise<void>
}) {
return (
<>
<h1 className="mb-1">Tailscale SSH server</h1>
<p className="description mb-10">
Run a Tailscale SSH server on this device and allow other devices in
your tailnet to SSH into it.{" "}
<a
href="https://tailscale.com/kb/1193/tailscale-ssh/"
className="text-indigo-700"
target="_blank"
>
Learn more &rarr;
</a>
</p>
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
<Toggle
checked={runningSSH}
onChange={() => updatePrefs({ RunSSHSet: true, RunSSH: !runningSSH })}
disabled={readonly}
/>
<div className="text-black text-sm font-medium leading-tight">
Run Tailscale SSH server
</div>
</div>
<p className="text-neutral-500 text-sm leading-tight">
Remember to make sure that the{" "}
<a
href="https://login.tailscale.com/admin/acls/"
className="text-indigo-700"
target="_blank"
>
tailnet policy file
</a>{" "}
allows other devices to SSH into this device.
</p>
</>
)
}

View File

@@ -7,13 +7,14 @@ export enum AuthType {
}
export type AuthResponse = {
ok: boolean
authNeeded?: AuthType
}
export type SessionsCallbacks = {
new: () => Promise<string> // creates new auth session and returns authURL
wait: () => Promise<void> // blocks until auth is completed
canManageNode: boolean
viewerIdentity?: {
loginName: string
nodeName: string
nodeIP: string
profilePicUrl?: string
}
}
// useAuth reports and refreshes Tailscale auth status
@@ -40,6 +41,7 @@ export default function useAuth() {
default:
setLoading(false)
}
return d
})
.catch((error) => {
setLoading(false)
@@ -50,30 +52,32 @@ export default function useAuth() {
const newSession = useCallback(() => {
return apiFetch("/auth/session/new", "GET")
.then((r) => r.json())
.then((d) => d.authUrl)
.catch((error) => {
console.error(error)
.then((d) => {
if (d.authUrl) {
window.open(d.authUrl, "_blank")
// refresh data when auth complete
apiFetch("/auth/session/wait", "GET").then(() => loadAuth())
}
})
}, [])
const waitForSessionCompletion = useCallback(() => {
return apiFetch("/auth/session/wait", "GET")
.then(() => loadAuth()) // refresh auth data
.catch((error) => {
console.error(error)
})
}, [])
useEffect(() => {
loadAuth()
loadAuth().then((d) => {
if (
!d.canManageNode &&
new URLSearchParams(window.location.search).get("check") == "now"
) {
newSession()
}
})
}, [])
return {
data,
loading,
sessions: {
new: newSession,
wait: waitForSessionCompletion,
},
newSession,
}
}

View File

@@ -3,9 +3,14 @@ import { apiFetch, setUnraidCsrfToken } from "src/api"
export type NodeData = {
Profile: UserProfile
Status: string
Status: NodeState
DeviceName: string
OS: string
IP: string
IPv6: string
ID: string
KeyExpiry: string
KeyExpired: boolean
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
@@ -15,10 +20,24 @@ export type NodeData = {
IsUnraid: boolean
UnraidToken: string
IPNVersion: string
URLPrefix: string
DomainName: string
TailnetName: string
IsTagged: boolean
Tags: string[]
RunningSSHServer: boolean
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
}
type NodeState =
| "NoState"
| "NeedsLogin"
| "NeedsMachineAuth"
| "Stopped"
| "Starting"
| "Running"
export type UserProfile = {
LoginName: string
DisplayName: string
@@ -32,6 +51,11 @@ export type NodeUpdate = {
ForceLogout?: boolean
}
export type PrefsUpdate = {
RunSSHSet?: boolean
RunSSH?: boolean
}
// useNodeData returns basic data about the current node.
export default function useNodeData() {
const [data, setData] = useState<NodeData>()
@@ -75,7 +99,7 @@ export default function useNodeData() {
: data.AdvertiseExitNode,
}
apiFetch("/data", "POST", update, { up: "true" })
return apiFetch("/data", "POST", update, { up: "true" })
.then((r) => r.json())
.then((r) => {
setIsPosting(false)
@@ -89,11 +113,45 @@ export default function useNodeData() {
}
refreshData()
})
.catch((err) => alert("Failed operation: " + err.message))
.catch((err) => {
setIsPosting(false)
alert("Failed operation: " + err.message)
throw err
})
},
[data]
)
const updatePrefs = useCallback(
(p: PrefsUpdate) => {
setIsPosting(true)
if (data) {
const optimisticUpdates = data
if (p.RunSSHSet) {
optimisticUpdates.RunningSSHServer = Boolean(p.RunSSH)
}
// Reflect the pref change immediatley on the frontend,
// then make the prefs PATCH. If the request fails,
// data will be updated to it's previous value in
// onComplete below.
setData(optimisticUpdates)
}
const onComplete = () => {
setIsPosting(false)
refreshData() // refresh data after PATCH finishes
}
return apiFetch("/local/v0/prefs", "PATCH", p)
.then(onComplete)
.catch(() => {
onComplete()
alert("Failed to update prefs")
})
},
[setIsPosting, refreshData, setData, data]
)
useEffect(
() => {
// Initial data load.
@@ -113,5 +171,5 @@ export default function useNodeData() {
[]
)
return { data, refreshData, updateNode, isPosting }
return { data, refreshData, updateNode, updatePrefs, isPosting }
}

View File

@@ -0,0 +1,4 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@@ -0,0 +1,11 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_15367_14595)">
<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_15367_14595">
<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1,4 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 635 B

View File

@@ -2,6 +2,138 @@
@tailwind components;
@tailwind utilities;
@layer base {
h1 {
@apply text-neutral-800 text-[22px] font-medium leading-[30.80px];
}
h2 {
@apply text-neutral-500 text-sm font-medium uppercase leading-tight tracking-wide;
}
}
@layer components {
.card {
@apply p-5 bg-white rounded-lg border border-gray-200;
}
.card h1 {
@apply text-neutral-800 text-lg font-medium leading-snug;
}
.card h2 {
@apply text-neutral-500 text-xs font-semibold uppercase tracking-wide;
}
.card tbody {
@apply flex flex-col gap-2;
}
.card td:first-child {
@apply w-40 text-neutral-500 text-sm leading-tight flex-shrink-0;
}
.card td:last-child {
@apply text-neutral-800 text-sm leading-tight;
}
.description {
@apply text-neutral-500 leading-snug
}
/**
* .toggle applies "Toggle" UI styles to input[type="checkbox"] form elements.
* You can use the -large and -small modifiers for size variants.
*/
.toggle {
@apply appearance-none relative w-10 h-5 rounded-full bg-neutral-300 cursor-pointer;
transition: background-color 200ms ease-in-out;
}
.toggle:disabled {
@apply bg-neutral-200;
@apply cursor-not-allowed;
}
.toggle:checked {
@apply bg-indigo-500;
}
.toggle:checked:disabled {
@apply bg-indigo-300;
}
.toggle:focus {
@apply outline-none ring;
}
.toggle::after {
@apply absolute bg-white rounded-full will-change-[width];
@apply w-3.5 h-3.5 m-[0.1875rem] translate-x-0;
content: " ";
transition: width 200ms ease, transform 200ms ease;
}
.toggle:checked::after {
@apply translate-x-5;
}
.toggle:checked:disabled::after {
@apply bg-indigo-50;
}
.toggle:enabled:active::after {
@apply w-[1.125rem];
}
.toggle:checked:enabled:active::after {
@apply w-[1.125rem] translate-x-3.5;
}
.toggle-large {
@apply w-12 h-6;
}
.toggle-large::after {
@apply m-1 w-4 h-4;
}
.toggle-large:checked::after {
@apply translate-x-6;
}
.toggle-large:enabled:active::after {
@apply w-6;
}
.toggle-large:checked:enabled:active::after {
@apply w-6 translate-x-4;
}
.toggle-small {
@apply w-6 h-3;
}
.toggle-small:focus {
/**
* We disable ring for .toggle-small because it is a
* small, inline element.
*/
@apply outline-none shadow-none;
}
.toggle-small::after {
@apply w-2 h-2 m-0.5;
}
.toggle-small:checked::after {
@apply translate-x-3;
}
.toggle-small:enabled:active::after {
@apply w-[0.675rem];
}
.toggle-small:checked:enabled:active::after {
@apply w-[0.675rem] translate-x-[0.55rem];
}
}
/**
* Non-Tailwind styles begin here.
*/

View File

@@ -0,0 +1,48 @@
import cx from "classnames"
import React, { HTMLAttributes } from "react"
export type BadgeColor =
| "blue"
| "green"
| "red"
| "orange"
| "yellow"
| "gray"
| "outline"
type Props = {
variant: "tag" | "status"
color: BadgeColor
} & HTMLAttributes<HTMLDivElement>
export default function Badge(props: Props) {
const { className, color, variant, ...rest } = props
return (
<div
className={cx(
"inline-flex items-center align-middle justify-center font-medium",
{
"border border-gray-200 bg-gray-200 text-gray-600": color === "gray",
"border border-green-50 bg-green-50 text-green-600":
color === "green",
"border border-blue-50 bg-blue-50 text-blue-600": color === "blue",
"border border-orange-50 bg-orange-50 text-orange-600":
color === "orange",
"border border-yellow-50 bg-yellow-50 text-yellow-600":
color === "yellow",
"border border-red-50 bg-red-50 text-red-600": color === "red",
"border border-gray-300 bg-white": color === "outline",
"rounded-full px-2 py-1 leading-none": variant === "status",
"rounded-sm px-1": variant === "tag",
},
className
)}
{...rest}
/>
)
}
Badge.defaultProps = {
color: "gray",
}

View File

@@ -0,0 +1,106 @@
import * as PopoverPrimitive from "@radix-ui/react-popover"
import cx from "classnames"
import React, { ReactNode } from "react"
type Props = {
className?: string
content: ReactNode
children: ReactNode
/**
* asChild renders the trigger element without wrapping it in a button. Use
* this when you want to use a `button` element as the trigger.
*/
asChild?: boolean
/**
* side is the side of the direction from the target element to render the
* popover.
*/
side?: "top" | "bottom" | "left" | "right"
/**
* sideOffset is how far from a give side to render the popover.
*/
sideOffset?: number
/**
* align is how to align the popover with the target element.
*/
align?: "start" | "center" | "end"
/**
* alignOffset is how far off of the alignment point to render the popover.
*/
alignOffset?: number
open?: boolean
onOpenChange?: (open: boolean) => void
}
/**
* Popover is a UI component that allows rendering unique controls in a floating
* popover, attached to a trigger element. It appears on click and manages focus
* on its own behalf.
*
* To use the Popover, pass the content as children, and give it a `trigger`:
*
* <Popover trigger={<span>Open popover</span>}>
* <p>Hello world!</p>
* </Popover>
*
* By default, the toggle is wrapped in an accessible <button> tag. You can
* customize by providing your own button and using the `asChild` prop.
*
* <Popover trigger={<Button>Hello</Button>} asChild>
* <p>Hello world!</p>
* </Popover>
*
* The former style is recommended whenever possible.
*/
export default function Popover(props: Props) {
const {
children,
className,
content,
side,
sideOffset,
align,
alignOffset,
asChild,
open,
onOpenChange,
} = props
return (
<PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
<PopoverPrimitive.Trigger asChild={asChild}>
{children}
</PopoverPrimitive.Trigger>
<PortalContainerContext.Consumer>
{(portalContainer) => (
<PopoverPrimitive.Portal container={portalContainer}>
<PopoverPrimitive.Content
className={cx(
"origin-radix-popover shadow-popover bg-white rounded-md z-50",
"state-open:animate-scale-in state-closed:animate-scale-out",
className
)}
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
collisionPadding={12}
>
{content}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
)}
</PortalContainerContext.Consumer>
</PopoverPrimitive.Root>
)
}
Popover.defaultProps = {
sideOffset: 10,
}
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
undefined
)

View File

@@ -1,18 +1,37 @@
import cx from "classnames"
import React from "react"
export default function ProfilePic({ url }: { url: string }) {
export default function ProfilePic({
url,
size = "large",
className,
}: {
url?: string
size?: "small" | "medium" | "large"
className?: string
}) {
return (
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
<div
className={cx(
"relative flex-shrink-0 rounded-full overflow-hidden",
{
"w-5 h-5": size === "small",
"w-[26px] h-[26px]": size === "medium",
"w-8 h-8": size === "large",
},
className
)}
>
{url ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
className="w-full h-full flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${url})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
<div className="w-full h-full flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
)

View File

@@ -0,0 +1,41 @@
import cx from "classnames"
import React, { ChangeEvent } from "react"
type Props = {
id?: string
className?: string
disabled?: boolean
checked: boolean
sizeVariant?: "small" | "medium" | "large"
onChange: (checked: boolean) => void
}
export default function Toggle(props: Props) {
const { className, id, disabled, checked, sizeVariant, onChange } = props
function handleChange(e: ChangeEvent<HTMLInputElement>) {
onChange(e.target.checked)
}
return (
<input
id={id}
type="checkbox"
className={cx(
"toggle",
{
"toggle-large": sizeVariant === "large",
"toggle-small": sizeVariant === "small",
},
className
)}
disabled={disabled}
checked={checked}
onChange={handleChange}
/>
)
}
Toggle.defaultProps = {
sizeVariant: "medium",
}

View File

@@ -18,32 +18,30 @@ import (
// authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client.
// The returned authResponse indicates if the user is authorized,
// and if additional steps are needed to authenticate the user.
// If the user is authenticated, but not authorized to use the client, an error is returned.
func authorizeSynology(r *http.Request) (resp authResponse, err error) {
func authorizeSynology(r *http.Request) (authorized bool, err error) {
if !hasSynoToken(r) {
return authResponse{OK: false, AuthNeeded: synoAuth}, nil
return false, nil
}
// authenticate the Synology user
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
return resp, fmt.Errorf("auth: %v: %s", err, out)
return false, fmt.Errorf("auth: %v: %s", err, out)
}
user := strings.TrimSpace(string(out))
// check if the user is in the administrators group
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
if err != nil {
return resp, err
return false, err
}
if !isAdmin {
return resp, errors.New("not a member of administrators group")
return false, errors.New("not a member of administrators group")
}
return authResponse{OK: true}, nil
return true, nil
}
// hasSynoToken returns true if the request include a SynoToken used for synology auth.

View File

@@ -47,14 +47,8 @@ export default defineConfig({
// This needs to be 127.0.0.1 instead of localhost, because of how our
// Go proxy connects to it.
host: "127.0.0.1",
// If you change the port, be sure to update the proxy in adminhttp.go too.
// If you change the port, be sure to update the proxy in assets.go too.
port: 4000,
// Don't proxy the WebSocket connection used for live reloading by running
// it on a separate port.
hmr: {
protocol: "ws",
port: 4001,
},
},
test: {
exclude: ["**/node_modules/**", "**/dist/**"],

View File

@@ -329,11 +329,11 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
// Client using system-specific auth.
switch distro.Get() {
case distro.Synology:
resp, _ := authorizeSynology(r)
return resp.OK
authorized, _ := authorizeSynology(r)
return authorized
case distro.QNAP:
resp, _ := authorizeQNAP(r)
return resp.OK
authorized, _ := authorizeQNAP(r)
return authorized
default:
return true // no additional auth for this distro
}
@@ -366,8 +366,18 @@ var (
)
type authResponse struct {
OK bool `json:"ok"` // true when user has valid auth session
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
CanManageNode bool `json:"canManageNode"`
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
}
// viewerIdentity is the Tailscale identity of the source node
// connected to this web client.
type viewerIdentity struct {
LoginName string `json:"loginName"`
NodeName string `json:"nodeName"`
NodeIP string `json:"nodeIP"`
ProfilePicURL string `json:"profilePicUrl,omitempty"`
}
// serverAPIAuth handles requests to the /api/auth endpoint
@@ -375,25 +385,27 @@ type authResponse struct {
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
var resp authResponse
session, _, err := s.getSession(r)
session, whois, err := s.getSession(r)
switch {
case err != nil && errors.Is(err, errNotUsingTailscale):
// not using tailscale, so perform platform auth
switch distro.Get() {
case distro.Synology:
resp, err = authorizeSynology(r)
authorized, err := authorizeSynology(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
if !authorized {
resp.AuthNeeded = synoAuth
}
case distro.QNAP:
resp, err = authorizeQNAP(r)
if err != nil {
if _, err := authorizeQNAP(r); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
default:
resp.OK = true // no additional auth for this distro
// no additional auth for this distro
}
case err != nil && (errors.Is(err, errNotOwner) ||
errors.Is(err, errNotUsingTailscale) ||
@@ -401,17 +413,28 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
errors.Is(err, errTaggedRemoteSource)):
// These cases are all restricted to the readonly view.
// No auth action to take.
resp = authResponse{OK: false}
resp.AuthNeeded = ""
case err != nil && !errors.Is(err, errNoSession):
// Any other error.
http.Error(w, err.Error(), http.StatusInternalServerError)
return
case session.isAuthorized(s.timeNow()):
resp = authResponse{OK: true}
resp.CanManageNode = true
resp.AuthNeeded = ""
default:
resp = authResponse{OK: false, AuthNeeded: tailscaleAuth}
resp.AuthNeeded = tailscaleAuth
}
if whois != nil {
resp.ViewerIdentity = &viewerIdentity{
LoginName: whois.UserProfile.LoginName,
NodeName: whois.Node.Name,
ProfilePicURL: whois.UserProfile.ProfilePicURL,
}
if addrs := whois.Node.Addresses; len(addrs) > 0 {
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
}
}
writeJSON(w, resp)
}
@@ -490,20 +513,37 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
}
type nodeData struct {
Profile tailcfg.UserProfile
Status string
DeviceName string
IP string
ID tailcfg.StableNodeID
Status string
DeviceName string
TailnetName string // TLS cert name
DomainName string
IP string // IPv4
IPv6 string
OS string
IPNVersion string
Profile tailcfg.UserProfile
IsTagged bool
Tags []string
KeyExpiry string // time.RFC3339
KeyExpired bool
TUNMode bool
IsSynology bool
DSMVersion int // 6 or 7, if IsSynology=true
IsUnraid bool
UnraidToken string
URLPrefix string // if set, the URL prefix the client is served behind
AdvertiseExitNode bool
AdvertiseRoutes string
LicensesURL string
TUNMode bool
IsSynology bool
DSMVersion int // 6 or 7, if IsSynology=true
IsUnraid bool
UnraidToken string
IPNVersion string
DebugMode string // empty when not running in any debug mode
RunningSSHServer bool
LicensesURL string
DebugMode string // empty when not running in any debug mode
}
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
@@ -517,9 +557,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0]
versionShort := strings.Split(st.Version, "-")[0]
var debugMode string
if s.mode == ManageServerMode {
debugMode = "full"
@@ -527,17 +564,43 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
debugMode = "login"
}
data := &nodeData{
Profile: profile,
Status: st.BackendState,
DeviceName: deviceName,
LicensesURL: licenses.LicensesURL(),
TUNMode: st.TUN,
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
DSMVersion: distro.DSMVersion(),
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
IPNVersion: versionShort,
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
ID: st.Self.ID,
Status: st.BackendState,
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
OS: st.Self.OS,
IPNVersion: strings.Split(st.Version, "-")[0],
Profile: st.User[st.Self.UserID],
IsTagged: st.Self.IsTagged(),
KeyExpired: st.Self.Expired,
TUNMode: st.TUN,
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
DSMVersion: distro.DSMVersion(),
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
RunningSSHServer: prefs.RunSSH,
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
LicensesURL: licenses.LicensesURL(),
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
}
for _, ip := range st.TailscaleIPs {
if ip.Is4() {
data.IP = ip.String()
} else if ip.Is6() {
data.IPv6 = ip.String()
}
if data.IP != "" && data.IPv6 != "" {
break
}
}
if st.CurrentTailnet != nil {
data.TailnetName = st.CurrentTailnet.MagicDNSSuffix
data.DomainName = st.CurrentTailnet.Name
}
if st.Self.Tags != nil {
data.Tags = st.Self.Tags.AsSlice()
}
if st.Self.KeyExpiry != nil {
data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
}
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
@@ -549,9 +612,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
data.AdvertiseRoutes += r.String()
}
}
if len(st.TailscaleIPs) != 0 {
data.IP = st.TailscaleIPs[0].String()
}
writeJSON(w, *data)
}
@@ -744,12 +804,9 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
// 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",
"/v0/prefs",
}
// csrfKey returns a key that can be used for CSRF protection.

View File

@@ -410,14 +410,27 @@ func TestAuthorizeRequest(t *testing.T) {
}
func TestServeAuth(t *testing.T) {
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)}
self := &ipnstate.PeerStatus{
ID: "self",
UserID: user.ID,
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
}
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user}
remoteIP := "100.100.100.101"
remoteNode := &apitype.WhoIsResponse{
Node: &tailcfg.Node{
Name: "nodey",
ID: 1,
Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
},
UserProfile: user,
}
vi := &viewerIdentity{
LoginName: user.LoginName,
NodeName: remoteNode.Node.Name,
NodeIP: remoteIP,
ProfilePicURL: user.ProfilePicURL,
}
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
@@ -481,7 +494,7 @@ func TestServeAuth(t *testing.T) {
name: "no-session",
path: "/api/auth",
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: false, AuthNeeded: tailscaleAuth},
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
wantNewCookie: false,
wantSession: nil,
},
@@ -506,7 +519,7 @@ func TestServeAuth(t *testing.T) {
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: false, AuthNeeded: tailscaleAuth},
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
@@ -554,7 +567,7 @@ func TestServeAuth(t *testing.T) {
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: true},
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,

View File

@@ -202,6 +202,13 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
"@babel/runtime@^7.13.10":
version "7.23.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"
integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.22.15":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
@@ -369,6 +376,33 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d"
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
"@floating-ui/core@^1.4.2":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.0.tgz#5c05c60d5ae2d05101c3021c1a2a350ddc027f8c"
integrity sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==
dependencies:
"@floating-ui/utils" "^0.1.3"
"@floating-ui/dom@^1.5.1":
version "1.5.3"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa"
integrity sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==
dependencies:
"@floating-ui/core" "^1.4.2"
"@floating-ui/utils" "^0.1.3"
"@floating-ui/react-dom@^2.0.0":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.4.tgz#b076fafbdfeb881e1d86ae748b7ff95150e9f3ec"
integrity sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==
dependencies:
"@floating-ui/dom" "^1.5.1"
"@floating-ui/utils@^0.1.3":
version "0.1.6"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9"
integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==
"@jest/schemas@^29.6.0":
version "29.6.0"
resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.0.tgz#0f4cb2c8e3dca80c135507ba5635a4fd755b0040"
@@ -429,6 +463,197 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@radix-ui/primitive@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd"
integrity sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-arrow@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d"
integrity sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-context@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dismissable-layer@1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-focus-guards@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad"
integrity sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-focus-scope@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525"
integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-id@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0"
integrity sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-popover@^1.0.6":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c"
integrity sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-focus-guards" "1.0.1"
"@radix-ui/react-focus-scope" "1.0.4"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-popper" "1.1.3"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-use-controllable-state" "1.0.1"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-popper@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42"
integrity sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==
dependencies:
"@babel/runtime" "^7.13.10"
"@floating-ui/react-dom" "^2.0.0"
"@radix-ui/react-arrow" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-use-rect" "1.0.1"
"@radix-ui/react-use-size" "1.0.1"
"@radix-ui/rect" "1.0.1"
"@radix-ui/react-portal@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15"
integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-presence@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
integrity sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-primitive@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0"
integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-slot@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-use-callback-ref@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
integrity sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-controllable-state@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286"
integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-layout-effect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
integrity sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-rect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2"
integrity sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/rect" "1.0.1"
"@radix-ui/react-use-size@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz#1c5f5fea940a7d7ade77694bb98116fb49f870b2"
integrity sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/rect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.0.1.tgz#bf8e7d947671996da2e30f4904ece343bc4a883f"
integrity sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@rollup/pluginutils@^5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.0.2.tgz#012b8f53c71e4f6f9cb317e311df1404f56e7a33"
@@ -741,6 +966,13 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.1.1:
version "1.2.3"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.3.tgz#14aeb7fb692bbb72d69bebfa47279c1fd725e954"
integrity sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==
dependencies:
tslib "^2.0.0"
assertion-error@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
@@ -936,6 +1168,11 @@ deep-eql@^4.1.2:
dependencies:
type-detect "^4.0.0"
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
didyoumean@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
@@ -1066,6 +1303,11 @@ get-func-name@^2.0.0:
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==
get-nonce@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@@ -1140,6 +1382,13 @@ inherits@2:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
dependencies:
loose-envify "^1.0.0"
is-arrayish@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -1228,7 +1477,7 @@ local-pkg@^0.4.3:
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963"
integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==
loose-envify@^1.1.0:
loose-envify@^1.0.0, loose-envify@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -1510,6 +1759,34 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-remove-scroll-bar@^2.3.3:
version "2.3.4"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9"
integrity sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==
dependencies:
react-style-singleton "^2.2.1"
tslib "^2.0.0"
react-remove-scroll@2.5.5:
version "2.5.5"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77"
integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==
dependencies:
react-remove-scroll-bar "^2.3.3"
react-style-singleton "^2.2.1"
tslib "^2.1.0"
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-style-singleton@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==
dependencies:
get-nonce "^1.0.0"
invariant "^2.2.4"
tslib "^2.0.0"
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
@@ -1542,6 +1819,11 @@ recrawl-sync@^2.0.3:
sucrase "^3.20.3"
tslib "^1.9.3"
regenerator-runtime@^0.14.0:
version "0.14.0"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@@ -1742,6 +2024,11 @@ tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0, tslib@^2.1.0:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
type-detect@^4.0.0, type-detect@^4.0.5:
version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
@@ -1765,6 +2052,26 @@ update-browserslist-db@^1.0.11:
escalade "^3.1.1"
picocolors "^1.0.0"
use-callback-ref@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5"
integrity sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==
dependencies:
tslib "^2.0.0"
use-sidecar@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==
dependencies:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
util-deprecate@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -1857,6 +2164,13 @@ why-is-node-running@^2.2.2:
siginfo "^2.0.0"
stackback "0.0.2"
wouter@^2.11.0:
version "2.12.1"
resolved "https://registry.yarnpkg.com/wouter/-/wouter-2.12.1.tgz#11d913324c6320b679873783acb15ea3523b8521"
integrity sha512-G7a6JMSLSNcu6o8gdOfIzqxuo8Qx1qs+9rpVnlurH69angsSFPZP5gESNuVNeJct/MGpQg191pDo4HUjTx7IIQ==
dependencies:
use-sync-external-store "^1.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"

View File

@@ -180,6 +180,8 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
// plugin manager to be persistent.
// TODO(awly): implement Unraid updates using the 'plugin' CLI.
return nil, false
case distro.QNAP:
return up.updateQNAP, true
}
switch {
case haveExecutable("pacman"):
@@ -260,6 +262,9 @@ func (up *Updater) updateSynology() error {
if up.Version != "" {
return errors.New("installing a specific version on Synology is not supported")
}
if err := requireRoot(); err != nil {
return err
}
// Get the latest version and list of SPKs from pkgs.tailscale.com.
dsmVersion := distro.DSMVersion()
@@ -277,9 +282,6 @@ func (up *Updater) updateSynology() error {
return fmt.Errorf("cannot find Synology package for os=%s arch=%s, please report a bug with your device model", osName, arch)
}
if err := requireRoot(); err != nil {
return err
}
if !up.confirm(latest.SPKsVersion) {
return nil
}
@@ -413,17 +415,25 @@ func (up *Updater) updateDebLike() error {
// we're not updating them:
"-o", "APT::Get::List-Cleanup=0",
)
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return err
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get update failed: %w; output:\n%s", err, out)
}
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return err
for i := 0; i < 2; i++ {
out, err := exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver).CombinedOutput()
if err != nil {
if !bytes.Contains(out, []byte(`dpkg was interrupted`)) {
return fmt.Errorf("apt-get install failed: %w; output:\n%s", err, out)
}
up.Logf("apt-get install failed: %s; output:\n%s", err, out)
up.Logf("running dpkg --configure tailscale")
out, err = exec.Command("dpkg", "--force-confdef,downgrade", "--configure", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("dpkg --configure tailscale failed: %w; output:\n%s", err, out)
}
continue
}
break
}
return nil
@@ -606,11 +616,11 @@ func (up *Updater) updateAlpineLike() (err error) {
out, err := exec.Command("apk", "update").CombinedOutput()
if err != nil {
return fmt.Errorf("failed refresh apk repository indexes: %w, output: %q", err, out)
return fmt.Errorf("failed refresh apk repository indexes: %w, output:\n%s", err, out)
}
out, err = exec.Command("apk", "info", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking apk for latest tailscale version: %w, output: %q", err, out)
return fmt.Errorf("failed checking apk for latest tailscale version: %w, output:\n%s", err, out)
}
ver, err := parseAlpinePackageVersion(out)
if err != nil {
@@ -658,7 +668,7 @@ func (up *Updater) updateMacAppStore() error {
out, err := exec.Command("open", "https://apps.apple.com/us/app/tailscale/id1475387142").CombinedOutput()
if err != nil {
return fmt.Errorf("can't open the Tailscale page in App Store: %w, output: %q", err, string(out))
return fmt.Errorf("can't open the Tailscale page in App Store: %w, output:\n%s", err, string(out))
}
return nil
}
@@ -713,14 +723,6 @@ func (up *Updater) updateWindows() error {
up.Logf("success.")
return nil
}
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
arch := runtime.GOARCH
if arch == "386" {
arch = "x86"
}
if !winutil.IsCurrentProcessElevated() {
return errors.New(`update must be run as Administrator
@@ -730,6 +732,14 @@ you can run the command prompt as Administrator one of these ways:
* press Windows+x, then press a
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
}
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
arch := runtime.GOARCH
if arch == "386" {
arch = "x86"
}
if !up.confirm(ver) {
return nil
}
@@ -925,35 +935,41 @@ func (up *Updater) updateFreeBSD() (err error) {
out, err := exec.Command("pkg", "update").CombinedOutput()
if err != nil {
return fmt.Errorf("failed refresh pkg repository indexes: %w, output: %q", err, out)
return fmt.Errorf("failed refresh pkg repository indexes: %w, output:\n%s", err, out)
}
out, err = exec.Command("pkg", "rquery", "%v", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output: %q", err, out)
return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output:\n%s", err, out)
}
ver := string(bytes.TrimSpace(out))
if !up.confirm(ver) {
return nil
}
cmd := exec.Command("pkg", "upgrade", "tailscale")
cmd := exec.Command("pkg", "upgrade", "-y", "tailscale")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using pkg: %w", err)
}
// pkg does not automatically restart services after upgrade.
out, err = exec.Command("service", "tailscaled", "restart").CombinedOutput()
if err != nil {
return fmt.Errorf("failed to restart tailscaled after update: %w, output:\n%s", err, out)
}
return nil
}
func (up *Updater) updateLinuxBinary() error {
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
// Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil {
return err
}
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
@@ -1061,6 +1077,77 @@ func (up *Updater) unpackLinuxTarball(path string) error {
return nil
}
func (up *Updater) updateQNAP() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on QNAP is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "qpkg_cli --add Tailscale"`, err)
}
}()
out, err := exec.Command("qpkg_cli", "--upgradable", "Tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed to check if Tailscale is upgradable using qpkg_cli: %w, output: %q", err, out)
}
// Output should look like this:
//
// $ qpkg_cli -G Tailscale
// [Tailscale]
// upgradeStatus = 1
statusRe := regexp.MustCompile(`upgradeStatus = (\d)`)
m := statusRe.FindStringSubmatch(string(out))
if len(m) < 2 {
return fmt.Errorf("failed to check if Tailscale is upgradable using qpkg_cli, output: %q", out)
}
status, err := strconv.Atoi(m[1])
if err != nil {
return fmt.Errorf("cannot parse upgradeStatus from qpkg_cli output %q: %w", out, err)
}
// Possible status values:
// 0:can upgrade
// 1:can not upgrade
// 2:error
// 3:can not get rss information
// 4:qpkg not found
// 5:qpkg not installed
//
// We want status 0.
switch status {
case 0: // proceed with upgrade
case 1:
up.Logf("no update available")
return nil
case 2, 3, 4:
return fmt.Errorf("failed to check update status with qpkg_cli (upgradeStatus = %d)", status)
case 5:
return errors.New("Tailscale was not found in the QNAP App Center")
default:
return fmt.Errorf("failed to check update status with qpkg_cli (upgradeStatus = %d)", status)
}
// There doesn't seem to be a way to fetch what the available upgrade
// version is. Use the generic "latest" version in confirmation prompt.
if up.Confirm != nil && !up.Confirm("latest") {
return nil
}
up.Logf("c2n: running qpkg_cli --add Tailscale")
cmd := exec.Command("qpkg_cli", "--add", "Tailscale")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using qpkg_cli: %w", err)
}
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)

View File

@@ -7,13 +7,15 @@
package clientupdate
import (
"errors"
"fmt"
"os/exec"
"os/user"
"path/filepath"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/authenticode"
)
@@ -39,13 +41,14 @@ func launchTailscaleAsGUIUser(exePath string) error {
var token windows.Token
if u, err := user.Current(); err == nil && u.Name == "SYSTEM" {
sessionID := winutil.WTSGetActiveConsoleSessionId()
if sessionID != 0xFFFFFFFF {
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
return err
}
defer token.Close()
sessionID, err := wtsGetActiveSessionID()
if err != nil {
return fmt.Errorf("wtsGetActiveSessionID(): %w", err)
}
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
return fmt.Errorf("WTSQueryUserToken (0x%x): %w", sessionID, err)
}
defer token.Close()
}
cmd := exec.Command(exePath)
@@ -55,3 +58,27 @@ func launchTailscaleAsGUIUser(exePath string) error {
}
return cmd.Start()
}
func wtsGetActiveSessionID() (uint32, error) {
var (
sessionInfo *windows.WTS_SESSION_INFO
count uint32 = 0
)
const WTS_CURRENT_SERVER_HANDLE = 0
if err := windows.WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE, 0, 1, &sessionInfo, &count); err != nil {
return 0, fmt.Errorf("WTSEnumerateSessions: %w", err)
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessionInfo)))
current := unsafe.Pointer(sessionInfo)
for i := uint32(0); i < count; i++ {
session := (*windows.WTS_SESSION_INFO)(current)
if session.State == windows.WTSActive {
return session.SessionID, nil
}
current = unsafe.Add(current, unsafe.Sizeof(windows.WTS_SESSION_INFO{}))
}
return 0, errors.New("no active desktop sessions found")
}

View File

@@ -42,6 +42,7 @@ type setArgsT struct {
exitNodeAllowLANAccess bool
shieldsUp bool
runSSH bool
runWebClient bool
hostname string
advertiseRoutes string
advertiseDefaultRoute bool
@@ -73,6 +74,11 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version")
setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information")
// TODO(tailscale/corp#14335): during development only expose -webclient on dev and unstable builds
if version.GetMeta().IsDev || version.IsUnstableBuild() {
setf.BoolVar(&setArgs.runWebClient, "webclient", false, "run a web client, permitting access per tailnet admin's declared policy")
}
if safesocket.GOOSUsesPeerCreds(goos) {
setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
}
@@ -108,6 +114,7 @@ func runSet(ctx context.Context, args []string) (retErr error) {
ExitNodeAllowLANAccess: setArgs.exitNodeAllowLANAccess,
ShieldsUp: setArgs.shieldsUp,
RunSSH: setArgs.runSSH,
RunWebClient: setArgs.runWebClient,
Hostname: setArgs.hostname,
OperatorUser: setArgs.opUser,
ForceDaemon: setArgs.forceDaemon,

View File

@@ -161,6 +161,7 @@ type upArgsT struct {
exitNodeAllowLANAccess bool
shieldsUp bool
runSSH bool
runWebClient bool
forceReauth bool
forceDaemon bool
advertiseRoutes string
@@ -279,6 +280,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
prefs.AllowSingleHosts = upArgs.singleRoutes
prefs.ShieldsUp = upArgs.shieldsUp
prefs.RunSSH = upArgs.runSSH
prefs.RunWebClient = upArgs.runWebClient
prefs.AdvertiseRoutes = routes
prefs.AdvertiseTags = tags
prefs.Hostname = upArgs.hostname
@@ -435,10 +437,17 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
fatalf("%s", err)
}
if len(prefs.AdvertiseRoutes) > 0 {
if err := localClient.CheckIPForwarding(context.Background()); err != nil {
if len(prefs.AdvertiseRoutes) > 0 || prefs.AppConnector.Advertise {
// TODO(jwhited): compress CheckIPForwarding and CheckUDPGROForwarding
// into a single HTTP req.
if err := localClient.CheckIPForwarding(ctx); err != nil {
warnf("%v", err)
}
if runtime.GOOS == "linux" {
if err := localClient.CheckUDPGROForwarding(ctx); err != nil {
warnf("%v", err)
}
}
}
curPrefs, err := localClient.GetPrefs(ctx)
@@ -730,6 +739,7 @@ func init() {
addPrefFlagMapping("unattended", "ForceDaemon")
addPrefFlagMapping("operator", "OperatorUser")
addPrefFlagMapping("ssh", "RunSSH")
addPrefFlagMapping("webclient", "RunWebClient")
addPrefFlagMapping("nickname", "ProfileName")
addPrefFlagMapping("update-check", "AutoUpdate")
addPrefFlagMapping("auto-update", "AutoUpdate")
@@ -938,6 +948,8 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
panic(fmt.Sprintf("unhandled flag %q", f.Name))
case "ssh":
set(prefs.RunSSH)
case "webclient":
set(prefs.RunWebClient)
case "login-server":
set(prefs.ControlURL)
case "accept-routes":

View File

@@ -13,6 +13,7 @@ import (
"net"
"net/http"
"net/http/cgi"
"net/netip"
"os"
"os/signal"
"strings"
@@ -84,11 +85,13 @@ func runWeb(ctx context.Context, args []string) error {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
var hasPreviewCap bool
var selfIP netip.Addr
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
if err == nil && st.Self != nil && len(st.Self.TailscaleIPs) > 0 {
hasPreviewCap = st.Self.HasCap(tailcfg.CapabilityPreviewWebClient)
selfIP = st.Self.TailscaleIPs[0]
}
hasPreviewCap := st.Self.HasCap(tailcfg.CapabilityPreviewWebClient)
cliServerMode := web.LegacyServerMode
var existingWebClient bool
@@ -99,7 +102,7 @@ func runWeb(ctx context.Context, args []string) error {
cliServerMode = web.LoginServerMode
if !existingWebClient {
// Also start full client in tailscaled.
log.Printf("starting tailscaled web client at %s:5252\n", st.Self.TailscaleIPs[0])
log.Printf("starting tailscaled web client at %s:%d\n", selfIP.String(), web.ListenPort)
if err := setRunWebClient(ctx, true); err != nil {
return fmt.Errorf("starting web client in tailscaled: %w", err)
}

View File

@@ -76,7 +76,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+
tailscale.com/client/tailscale/apitype from tailscale.com/cmd/tailscale/cli+
tailscale.com/client/web from tailscale.com/cmd/tailscale/cli
tailscale.com/clientupdate from tailscale.com/cmd/tailscale/cli
💣 tailscale.com/clientupdate from tailscale.com/cmd/tailscale/cli
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/control/controlbase from tailscale.com/control/controlhttp

View File

@@ -133,6 +133,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/pkg/errors from github.com/gorilla/csrf
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
@@ -227,7 +228,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/client/tailscale from tailscale.com/derp+
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate 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+
@@ -278,6 +279,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
tailscale.com/net/netknob from tailscale.com/net/netns+
tailscale.com/net/netmon from tailscale.com/cmd/tailscaled+
tailscale.com/net/netns from tailscale.com/derp/derphttp+
@@ -360,6 +362,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 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+
tailscale.com/util/osuser from tailscale.com/ssh/tailssh+
tailscale.com/util/race from tailscale.com/net/dns/resolver
tailscale.com/util/racebuild from tailscale.com/logpolicy
tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+

View File

@@ -52,6 +52,10 @@ type Knobs struct {
// DisableDNSForwarderTCPRetries is whether the DNS forwarder should
// skip retrying truncated queries over TCP.
DisableDNSForwarderTCPRetries atomic.Bool
// SilentDisco is whether the node should suppress disco heartbeats to its
// peers.
SilentDisco atomic.Bool
}
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
@@ -74,6 +78,7 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability,
forceBackgroundSTUN = has(tailcfg.NodeAttrDebugForceBackgroundSTUN)
peerMTUEnable = has(tailcfg.NodeAttrPeerMTUEnable)
dnsForwarderDisableTCPRetries = has(tailcfg.NodeAttrDNSForwarderDisableTCPRetries)
silentDisco = has(tailcfg.NodeAttrSilentDisco)
)
if has(tailcfg.NodeAttrOneCGNATEnable) {
@@ -91,6 +96,7 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability,
k.DisableDeltaUpdates.Store(disableDeltaUpdates)
k.PeerMTUEnable.Store(peerMTUEnable)
k.DisableDNSForwarderTCPRetries.Store(dnsForwarderDisableTCPRetries)
k.SilentDisco.Store(silentDisco)
}
// AsDebugJSON returns k as something that can be marshalled with json.Marshal
@@ -109,5 +115,6 @@ func (k *Knobs) AsDebugJSON() map[string]any {
"DisableDeltaUpdates": k.DisableDeltaUpdates.Load(),
"PeerMTUEnable": k.PeerMTUEnable.Load(),
"DisableDNSForwarderTCPRetries": k.DisableDNSForwarderTCPRetries.Load(),
"SilentDisco": k.SilentDisco.Load(),
}
}

View File

@@ -120,4 +120,4 @@
in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
}
# nix-direnv cache busting line: sha256-ynBPVRKWPcoEbbZ/atvO6VdjHVwhnWcugSD3RGJA34E=
# nix-direnv cache busting line: sha256-/kuu7DKPklMZOvYqJpsOp3TeDG9KDEET4U0G+sq+4qY=

5
go.mod
View File

@@ -56,6 +56,7 @@ require (
github.com/pkg/sftp v1.13.6
github.com/prometheus/client_golang v1.17.0
github.com/prometheus/common v0.44.0
github.com/safchain/ethtool v0.3.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/tailscale/certstore v0.1.1-0.20231020161753-77811a65f4ff
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502
@@ -65,7 +66,7 @@ require (
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2
github.com/tailscale/web-client-prebuilt v0.0.0-20231114171715-25f8d12b3c2d
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90
github.com/tc-hib/winres v0.2.1
github.com/tcnksm/go-httpstat v0.2.0
@@ -91,7 +92,7 @@ require (
gvisor.dev/gvisor v0.0.0-20230928000133-4fe30062272c
honnef.co/go/tools v0.4.6
inet.af/peercred v0.0.0-20210906144145-0893ea02156a
inet.af/tcpproxy v0.0.0-20221017015627-91f861402626
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9
inet.af/wf v0.0.0-20221017222439-36129f591884
k8s.io/api v0.28.2
k8s.io/apimachinery v0.28.2

View File

@@ -1 +1 @@
sha256-ynBPVRKWPcoEbbZ/atvO6VdjHVwhnWcugSD3RGJA34E=
sha256-/kuu7DKPklMZOvYqJpsOp3TeDG9KDEET4U0G+sq+4qY=

10
go.sum
View File

@@ -783,6 +783,8 @@ github.com/ryancurrah/gomodguard v1.3.0 h1:q15RT/pd6UggBXVBuLps8BXRvl5GPBcwVA7BJ
github.com/ryancurrah/gomodguard v1.3.0/go.mod h1:ggBxb3luypPEzqVtq33ee7YSN35V28XeGnid8dnni50=
github.com/ryanrolds/sqlclosecheck v0.4.0 h1:i8SX60Rppc1wRuyQjMciLqIzV3xnoHB7/tXbr6RGYNI=
github.com/ryanrolds/sqlclosecheck v0.4.0/go.mod h1:TBRRjzL31JONc9i4XMinicuo+s+E8yKZ5FN8X3G6CKQ=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/sanposhiho/wastedassign/v2 v2.0.7 h1:J+6nrY4VW+gC9xFzUc+XjPD3g3wF3je/NsJFwFK7Uxc=
github.com/sanposhiho/wastedassign/v2 v2.0.7/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI=
github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw=
@@ -882,8 +884,8 @@ github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89 h1:7xU7AFQE83h0wz/
github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89/go.mod h1:OGMqrTzDqmJkGumUTtOv44Rp3/4xS+QFbE8Rn0AGlaU=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2 h1:KzNItTAwrc5UmP+JpzRa8ZVNs0x/boBXleVXZq4qd/U=
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/web-client-prebuilt v0.0.0-20231114171715-25f8d12b3c2d h1:6bpLeKxSPVTwxzoy+0SrDLEaa60G6jnGqgLAcdDhkDg=
github.com/tailscale/web-client-prebuilt v0.0.0-20231114171715-25f8d12b3c2d/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90 h1:lMGYrokOq9NKDw1UMBH7AsS4boZ41jcduvYaRIdedhE=
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
@@ -1435,8 +1437,8 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
inet.af/peercred v0.0.0-20210906144145-0893ea02156a h1:qdkS8Q5/i10xU2ArJMKYhVa1DORzBfYS/qA2UK2jheg=
inet.af/peercred v0.0.0-20210906144145-0893ea02156a/go.mod h1:FjawnflS/udxX+SvpsMgZfdqx2aykOlkISeAsADi5IU=
inet.af/tcpproxy v0.0.0-20221017015627-91f861402626 h1:2dMP3Ox/Wh5BiItwOt4jxRsfzkgyBrHzx2nW28Yg6nc=
inet.af/tcpproxy v0.0.0-20221017015627-91f861402626/go.mod h1:Tojt5kmHpDIR2jMojxzZK2w2ZR7OILODmUo2gaSwjrk=
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9 h1:zomTWJvjwLbKRgGameQtpK6DNFUbZ2oNJuWhgUkGp3M=
inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9/go.mod h1:Tojt5kmHpDIR2jMojxzZK2w2ZR7OILODmUo2gaSwjrk=
inet.af/wf v0.0.0-20221017222439-36129f591884 h1:zg9snq3Cpy50lWuVqDYM7AIRVTtU50y5WXETMFohW/Q=
inet.af/wf v0.0.0-20221017222439-36129f591884/go.mod h1:bSAQ38BYbY68uwpasXOTZo22dKGy9SNvI6PZFeKomZE=
k8s.io/api v0.28.2 h1:9mpl5mOb6vXZvqbQmankOfPIGiudghwCoLl1EYfUZbw=

View File

@@ -1 +1 @@
56d25cd9a2efe0eee3945518fc532ec45912ccb2
b6cb22c8e82f0e8c28f7f90ef5b43942c08ca223

View File

@@ -28,118 +28,195 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
"tailscale.com/util/goroutines"
"tailscale.com/util/httpm"
"tailscale.com/util/set"
"tailscale.com/util/syspolicy"
"tailscale.com/version"
"tailscale.com/version/distro"
)
var c2nLogHeap func(http.ResponseWriter, *http.Request) // non-nil on most platforms (c2n_pprof.go)
// c2nHandlers maps an HTTP method and URI path (without query parameters) to
// its handler. The exact method+path match is preferred, but if no entry
// exists for that, a map entry with an empty method is used as a fallback.
var c2nHandlers = map[methodAndPath]c2nHandler{
// Debug.
req("/echo"): handleC2NEcho,
req("/debug/goroutines"): handleC2NDebugGoroutines,
req("/debug/prefs"): handleC2NDebugPrefs,
req("/debug/metrics"): handleC2NDebugMetrics,
req("/debug/component-logging"): handleC2NDebugComponentLogging,
req("/debug/logheap"): handleC2NDebugLogHeap,
req("POST /logtail/flush"): handleC2NLogtailFlush,
req("POST /sockstats"): handleC2NSockStats,
// SSH
req("/ssh/usernames"): handleC2NSSHUsernames,
// Auto-updates.
req("GET /update"): handleC2NUpdateGet,
req("POST /update"): handleC2NUpdatePost,
// Wake-on-LAN.
req("POST /wol"): handleC2NWoL,
// Device posture.
req("GET /posture/identity"): handleC2NPostureIdentityGet,
// App Connectors.
req("GET /appconnector/routes"): handleC2NAppConnectorDomainRoutesGet,
}
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
type methodAndPath struct {
method string // empty string means fallback
path string // Request.URL.Path (without query string)
}
func req(s string) methodAndPath {
if m, p, ok := strings.Cut(s, " "); ok {
return methodAndPath{m, p}
}
return methodAndPath{"", s}
}
// c2nHandlerPaths is all the set of paths from c2nHandlers, without their HTTP methods.
// It's used to detect requests with a non-matching method.
var c2nHandlerPaths = set.Set[string]{}
func init() {
for k := range c2nHandlers {
c2nHandlerPaths.Add(k.path)
}
}
func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
// First try to match by both method and path,
if h, ok := c2nHandlers[methodAndPath{r.Method, r.URL.Path}]; ok {
h(b, w, r)
return
}
// Then try to match by just path.
if h, ok := c2nHandlers[methodAndPath{path: r.URL.Path}]; ok {
h(b, w, r)
return
}
if c2nHandlerPaths.Contains(r.URL.Path) {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
} else {
http.Error(w, "unknown c2n path", http.StatusBadRequest)
}
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/echo":
// Test handler.
body, _ := io.ReadAll(r.Body)
w.Write(body)
case "/update":
switch r.Method {
case httpm.GET:
b.handleC2NUpdateGet(w, r)
case httpm.POST:
b.handleC2NUpdatePost(w, r)
default:
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
case "/wol":
b.handleC2NWoL(w, r)
return
case "/logtail/flush":
if r.Method != "POST" {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
if b.TryFlushLogs() {
w.WriteHeader(http.StatusNoContent)
} else {
http.Error(w, "no log flusher wired up", http.StatusInternalServerError)
}
case "/posture/identity":
switch r.Method {
case httpm.GET:
b.handleC2NPostureIdentityGet(w, r)
default:
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
case "/debug/goroutines":
w.Header().Set("Content-Type", "text/plain")
w.Write(goroutines.ScrubbedGoroutineDump(true))
case "/debug/prefs":
writeJSON(w, b.Prefs())
case "/debug/metrics":
w.Header().Set("Content-Type", "text/plain")
clientmetric.WritePrometheusExpositionFormat(w)
case "/debug/component-logging":
component := r.FormValue("component")
secs, _ := strconv.Atoi(r.FormValue("secs"))
if secs == 0 {
secs -= 1
}
until := b.clock.Now().Add(time.Duration(secs) * time.Second)
err := b.SetComponentDebugLogging(component, until)
var res struct {
Error string `json:",omitempty"`
}
if err != nil {
res.Error = err.Error()
}
writeJSON(w, res)
case "/debug/logheap":
if c2nLogHeap != nil {
c2nLogHeap(w, r)
} else {
http.Error(w, "not implemented", http.StatusNotImplemented)
return
}
case "/ssh/usernames":
var req tailcfg.C2NSSHUsernamesRequest
if r.Method == "POST" {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
res, err := b.getSSHUsernames(&req)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
writeJSON(w, res)
case "/sockstats":
if r.Method != "POST" {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain")
if b.sockstatLogger == nil {
http.Error(w, "no sockstatLogger", http.StatusInternalServerError)
return
}
b.sockstatLogger.Flush()
fmt.Fprintf(w, "logid: %s\n", b.sockstatLogger.LogID())
fmt.Fprintf(w, "debug info: %v\n", sockstats.DebugInfo())
default:
http.Error(w, "unknown c2n path", http.StatusBadRequest)
func handleC2NEcho(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
// Test handler.
body, _ := io.ReadAll(r.Body)
w.Write(body)
}
func handleC2NLogtailFlush(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
if b.TryFlushLogs() {
w.WriteHeader(http.StatusNoContent)
} else {
http.Error(w, "no log flusher wired up", http.StatusInternalServerError)
}
}
func (b *LocalBackend) handleC2NUpdateGet(w http.ResponseWriter, r *http.Request) {
func handleC2NDebugGoroutines(_ *LocalBackend, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write(goroutines.ScrubbedGoroutineDump(true))
}
func handleC2NDebugPrefs(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
writeJSON(w, b.Prefs())
}
func handleC2NDebugMetrics(_ *LocalBackend, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
clientmetric.WritePrometheusExpositionFormat(w)
}
func handleC2NDebugComponentLogging(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
component := r.FormValue("component")
secs, _ := strconv.Atoi(r.FormValue("secs"))
if secs == 0 {
secs -= 1
}
until := b.clock.Now().Add(time.Duration(secs) * time.Second)
err := b.SetComponentDebugLogging(component, until)
var res struct {
Error string `json:",omitempty"`
}
if err != nil {
res.Error = err.Error()
}
writeJSON(w, res)
}
var c2nLogHeap func(http.ResponseWriter, *http.Request) // non-nil on most platforms (c2n_pprof.go)
func handleC2NDebugLogHeap(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
if c2nLogHeap == nil {
// Not implemented on platforms trying to optimize for binary size or
// reduced memory usage.
http.Error(w, "not implemented", http.StatusNotImplemented)
return
}
c2nLogHeap(w, r)
}
func handleC2NSSHUsernames(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
var req tailcfg.C2NSSHUsernamesRequest
if r.Method == "POST" {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
res, err := b.getSSHUsernames(&req)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
writeJSON(w, res)
}
func handleC2NSockStats(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
if b.sockstatLogger == nil {
http.Error(w, "no sockstatLogger", http.StatusInternalServerError)
return
}
b.sockstatLogger.Flush()
fmt.Fprintf(w, "logid: %s\n", b.sockstatLogger.LogID())
fmt.Fprintf(w, "debug info: %v\n", sockstats.DebugInfo())
}
// handleC2NAppConnectorDomainRoutesGet handles returning the domains
// that the app connector is responsible for, as well as the resolved
// IP addresses for each domain. If the node is not configured as
// an app connector, an empty map is returned.
func handleC2NAppConnectorDomainRoutesGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /appconnector/routes received")
var res tailcfg.C2NAppConnectorDomainRoutesResponse
if b.appConnector == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
return
}
res.Domains = b.appConnector.DomainRoutes()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received")
res := b.newC2NUpdateResponse()
@@ -149,7 +226,7 @@ func (b *LocalBackend) handleC2NUpdateGet(w http.ResponseWriter, r *http.Request
json.NewEncoder(w).Encode(res)
}
func (b *LocalBackend) handleC2NUpdatePost(w http.ResponseWriter, r *http.Request) {
func handleC2NUpdatePost(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: POST /update received")
res := b.newC2NUpdateResponse()
defer func() {
@@ -203,7 +280,7 @@ func (b *LocalBackend) handleC2NUpdatePost(w http.ResponseWriter, r *http.Reques
return
}
cmd := exec.Command(cmdTS, "update", "--yes")
cmd := tailscaleUpdateCmd(cmdTS)
buf := new(bytes.Buffer)
cmd.Stdout = buf
cmd.Stderr = buf
@@ -225,7 +302,7 @@ func (b *LocalBackend) handleC2NUpdatePost(w http.ResponseWriter, r *http.Reques
}()
}
func (b *LocalBackend) handleC2NPostureIdentityGet(w http.ResponseWriter, r *http.Request) {
func handleC2NPostureIdentityGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /posture/identity received")
res := tailcfg.C2NPostureIdentityResponse{}
@@ -303,31 +380,58 @@ func findCmdTailscale() (string, error) {
if err != nil {
return "", err
}
var ts string
switch runtime.GOOS {
case "linux":
if self == "/usr/sbin/tailscaled" || self == "/usr/bin/tailscaled" {
return "/usr/bin/tailscale", nil
ts = "/usr/bin/tailscale"
}
if self == "/usr/local/sbin/tailscaled" || self == "/usr/local/bin/tailscaled" {
return "/usr/local/bin/tailscale", nil
ts = "/usr/local/bin/tailscale"
}
if distro.Get() == distro.QNAP {
// The volume under /share/ where qpkg are installed is not
// predictable. But the rest of the path is.
ok, err := filepath.Match("/share/*/.qpkg/Tailscale/tailscaled", self)
if err == nil && ok {
ts = filepath.Join(filepath.Dir(self), "tailscale")
}
}
return "", errors.New("tailscale not found in expected place")
case "windows":
dir := filepath.Dir(self)
ts := filepath.Join(dir, "tailscale.exe")
if fi, err := os.Stat(ts); err == nil && fi.Mode().IsRegular() {
return ts, nil
ts = filepath.Join(filepath.Dir(self), "tailscale.exe")
case "freebsd":
if self == "/usr/local/bin/tailscaled" {
ts = "/usr/local/bin/tailscale"
}
return "", errors.New("tailscale.exe not found in expected place")
default:
return "", fmt.Errorf("unsupported OS %v", runtime.GOOS)
}
return "", fmt.Errorf("unsupported OS %v", runtime.GOOS)
if ts != "" && regularFileExists(ts) {
return ts, nil
}
return "", errors.New("tailscale executable not found in expected place")
}
func (b *LocalBackend) handleC2NWoL(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
func tailscaleUpdateCmd(cmdTS string) *exec.Cmd {
if runtime.GOOS != "linux" {
return exec.Command(cmdTS, "update", "--yes")
}
if _, err := exec.LookPath("systemd-run"); err != nil {
return exec.Command(cmdTS, "update", "--yes")
}
// When systemd-run is available, use it to run the update command. This
// creates a new temporary unit separate from the tailscaled unit. When
// tailscaled is restarted during the update, systemd won't kill this
// temporary update unit, which could cause unexpected breakage.
return exec.Command("systemd-run", "--wait", "--pipe", "--collect", cmdTS, "update", "--yes")
}
func regularFileExists(path string) bool {
fi, err := os.Stat(path)
return err == nil && fi.Mode().IsRegular()
}
func handleC2NWoL(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
r.ParseForm()
var macs []net.HardwareAddr
for _, macStr := range r.Form["mac"] {

View File

@@ -34,6 +34,7 @@ import (
"gvisor.dev/gvisor/pkg/tcpip"
"tailscale.com/appc"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/clientupdate"
"tailscale.com/control/controlclient"
"tailscale.com/control/controlknobs"
"tailscale.com/doctor"
@@ -54,6 +55,7 @@ import (
"tailscale.com/net/dnscache"
"tailscale.com/net/dnsfallback"
"tailscale.com/net/interfaces"
"tailscale.com/net/netkernelconf"
"tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/netutil"
@@ -209,7 +211,6 @@ type LocalBackend struct {
ccGen clientGen // function for producing controlclient; lazily populated
sshServer SSHServer // or nil, initialized lazily.
appConnector *appc.AppConnector // or nil, initialized when configured.
webClient webClient
notify func(ipn.Notify)
cc controlclient.Client
ccAuto *controlclient.Auto // if cc is of type *controlclient.Auto
@@ -265,15 +266,20 @@ type LocalBackend struct {
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
currentUser ipnauth.WindowsToken
c2nUpdateStatus updateStatus
currentUser ipnauth.WindowsToken
selfUpdateProgress []ipnstate.UpdateProgress
lastSelfUpdateState ipnstate.SelfUpdateStatus
// ServeConfig fields. (also guarded by mu)
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
webClient webClient
webClientListeners map[netip.AddrPort]*localListener // listeners for local web client traffic
serveListeners map[netip.AddrPort]*localListener // listeners for local serve traffic
serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *reverseProxy
// statusLock must be held before calling statusChanged.Wait() or
@@ -371,6 +377,8 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
loginFlags: loginFlags,
clock: clock,
activeWatchSessions: make(set.Set[string]),
selfUpdateProgress: make([]ipnstate.UpdateProgress, 0),
lastSelfUpdateState: ipnstate.UpdateFinished,
}
netMon := sys.NetMon.Get()
@@ -1120,15 +1128,19 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
b.logf("[v1] TKA sync error: %v", err)
}
b.mu.Lock()
if b.tka != nil {
head, err := b.tka.authority.Head().MarshalText()
if err != nil {
b.logf("[v1] error marshalling tka head: %v", err)
// As we stepped outside of the lock, it's possible for b.cc
// to now be nil.
if b.cc != nil {
if b.tka != nil {
head, err := b.tka.authority.Head().MarshalText()
if err != nil {
b.logf("[v1] error marshalling tka head: %v", err)
} else {
b.cc.SetTKAHead(string(head))
}
} else {
b.cc.SetTKAHead(string(head))
b.cc.SetTKAHead("")
}
} else {
b.cc.SetTKAHead("")
}
if !envknob.TKASkipSignatureCheck() {
@@ -1532,6 +1544,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
hostinfo.FrontendLogID = opts.FrontendLogID
hostinfo.Userspace.Set(b.sys.IsNetstack())
hostinfo.UserspaceRouter.Set(b.sys.IsNetstackRouter())
hostinfo.AppConnector.Set(b.appConnector != nil)
b.logf.JSON(1, "Hostinfo", hostinfo)
// TODO(apenwarr): avoid the need to reinit controlclient.
@@ -1776,6 +1789,18 @@ func (b *LocalBackend) updateFilterLocked(netMap *netmap.NetworkMap, prefs ipn.P
logNetsB.AddPrefix(r)
}
}
// App connectors handle DNS requests for app domains over PeerAPI (corp#11961),
// but a safety check verifies the requesting peer has at least permission
// to send traffic to 0.0.0.0:53 (or 2000:: for IPv6) before handling the DNS
// request (see peerAPIHandler.replyToDNSQueries in peerapi.go).
// The correct filter rules are synthesized by the coordination server
// and sent down, but the address needs to be part of the 'local net' for the
// filter package to even bother checking the filter rules, so we set them here.
if prefs.AppConnector().Advertise {
localNetsB.Add(netip.MustParseAddr("0.0.0.0"))
localNetsB.Add(netip.MustParseAddr("::0"))
}
}
localNets, _ := localNetsB.IPSet()
logNets, _ := logNetsB.IPSet()
@@ -3167,12 +3192,6 @@ func (b *LocalBackend) peerAPIServicesLocked() (ret []tailcfg.Service) {
Port: 1, // version
})
}
if b.appConnector != nil {
ret = append(ret, tailcfg.Service{
Proto: tailcfg.AppConnector,
Port: 1, // version
})
}
return ret
}
@@ -3246,6 +3265,11 @@ func (b *LocalBackend) blockEngineUpdates(block bool) {
// b.mu must be held.
func (b *LocalBackend) reconfigAppConnectorLocked(nm *netmap.NetworkMap, prefs ipn.PrefsView) {
const appConnectorCapName = "tailscale.com/app-connectors"
defer func() {
if b.hostinfo != nil {
b.hostinfo.AppConnector.Set(b.appConnector != nil)
}
}()
if !prefs.AppConnector().Advertise {
b.appConnector = nil
@@ -3704,6 +3728,7 @@ func (b *LocalBackend) initPeerAPIListener() {
taildrop: taildrop.ManagerOptions{
Logf: b.logf,
Clock: tstime.DefaultClock{Clock: b.clock},
State: b.store,
Dir: fileRoot,
DirectFileMode: b.directFileRoot != "",
AvoidFinalRename: !b.directFileDoFinalRename,
@@ -3881,7 +3906,8 @@ func (b *LocalBackend) routerConfig(cfg *wgcfg.Config, prefs ipn.PrefsView, oneC
if err != nil {
b.logf("failed to discover interface ips: %v", err)
}
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
switch runtime.GOOS {
case "linux", "windows", "darwin", "ios":
rs.LocalRoutes = internalIPs // unconditionally allow access to guest VM networks
if prefs.ExitNodeAllowLANAccess() {
rs.LocalRoutes = append(rs.LocalRoutes, externalIPs...)
@@ -3891,6 +3917,10 @@ func (b *LocalBackend) routerConfig(cfg *wgcfg.Config, prefs ipn.PrefsView, oneC
rs.Routes = append(rs.Routes, externalIPs...)
}
b.logf("allowing exit node access to local IPs: %v", rs.LocalRoutes)
default:
if prefs.ExitNodeAllowLANAccess() {
b.logf("warning: ExitNodeAllowLANAccess has no effect on " + runtime.GOOS)
}
}
}
@@ -3940,6 +3970,7 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
// properly. This exists as an optimization to control to program fewer DNS
// records that have ingress enabled but are not actually being used.
hi.WireIngress = b.wantIngressLocked()
hi.AppConnector.Set(prefs.AppConnector().Advertise)
}
// enterState transitions the backend into newState, updating internal
@@ -4348,6 +4379,8 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
}
b.capFileSharing = fs
b.magicConn().SetSilentDisco(b.ControlKnobs().SilentDisco.Load())
b.setDebugLogsByCapabilityLocked(nm)
// See the netns package for documentation on what this capability does.
@@ -4475,6 +4508,11 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
}
if b.ShouldRunWebClient() {
handlePorts = append(handlePorts, webClientPort)
// don't listen on netmap addresses if we're in userspace mode
if !b.sys.IsNetstack() {
b.updateWebClientListenersLocked()
}
}
b.reloadServeConfigLocked(prefs)
@@ -4848,6 +4886,45 @@ func (b *LocalBackend) CheckIPForwarding() error {
return warn
}
// CheckUDPGROForwarding checks if the machine is optimally configured to
// forward UDP packets between the default route and Tailscale TUN interfaces.
// It returns an error if the check fails or if suboptimal configuration is
// detected. No error is returned if we are unable to gather the interface
// names from the relevant subsystems.
func (b *LocalBackend) CheckUDPGROForwarding() error {
if b.sys.IsNetstackRouter() {
return nil
}
// We return nil when the interface name or subsystem it's tied to can't be
// fetched. This is intentional as answering the question "are netdev
// features optimal for performance?" is a low priority in that situation.
tunSys, ok := b.sys.Tun.GetOK()
if !ok {
return nil
}
tunInterface, err := tunSys.Name()
if err != nil {
return nil
}
netmonSys, ok := b.sys.NetMon.GetOK()
if !ok {
return nil
}
state := netmonSys.InterfaceState()
if state == nil {
return nil
}
// We return warn or err. If err is non-nil there was a problem
// communicating with the kernel via ethtool semantics/ioctl. ethtool ioctl
// errors are interesting for our future selves as we consider tweaking
// netdev features automatically using similar API infra.
warn, err := netkernelconf.CheckUDPGROForwarding(tunInterface, state.DefaultRouteInterface)
if err != nil {
return err
}
return warn
}
// DERPMap returns the current DERPMap in use, or nil if not connected.
func (b *LocalBackend) DERPMap() *tailcfg.DERPMap {
b.mu.Lock()
@@ -5476,6 +5553,54 @@ func (b *LocalBackend) DebugBreakDERPConns() error {
return b.magicConn().DebugBreakDERPConns()
}
func (b *LocalBackend) pushSelfUpdateProgress(up ipnstate.UpdateProgress) {
b.mu.Lock()
defer b.mu.Unlock()
b.selfUpdateProgress = append(b.selfUpdateProgress, up)
b.lastSelfUpdateState = up.Status
}
func (b *LocalBackend) clearSelfUpdateProgress() {
b.mu.Lock()
defer b.mu.Unlock()
b.selfUpdateProgress = make([]ipnstate.UpdateProgress, 0)
b.lastSelfUpdateState = ipnstate.UpdateFinished
}
func (b *LocalBackend) GetSelfUpdateProgress() []ipnstate.UpdateProgress {
b.mu.Lock()
defer b.mu.Unlock()
res := make([]ipnstate.UpdateProgress, len(b.selfUpdateProgress))
copy(res, b.selfUpdateProgress)
return res
}
func (b *LocalBackend) DoSelfUpdate() {
b.mu.Lock()
updateState := b.lastSelfUpdateState
b.mu.Unlock()
// don't start an update if one is already in progress
if updateState == ipnstate.UpdateInProgress {
return
}
b.clearSelfUpdateProgress()
b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateInProgress, ""))
up, err := clientupdate.NewUpdater(clientupdate.Arguments{
Logf: func(format string, args ...any) {
b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateInProgress, fmt.Sprintf(format, args...)))
},
})
if err != nil {
b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFailed, err.Error()))
}
err = up.Update()
if err != nil {
b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFailed, err.Error()))
} else {
b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFinished, "tailscaled did not restart; please restart Tailscale manually."))
}
}
// ObserveDNSResponse passes a DNS response from the PeerAPI DNS server to the
// App Connector to enable route discovery.
func (b *LocalBackend) ObserveDNSResponse(res []byte) {

View File

@@ -1157,28 +1157,6 @@ func TestOfferingAppConnector(t *testing.T) {
}
}
func TestAppConnectorHostinfoService(t *testing.T) {
hasAppConnectorService := func(s []tailcfg.Service) bool {
for _, s := range s {
if s.Proto == tailcfg.AppConnector && s.Port == 1 {
return true
}
}
return false
}
b := newTestBackend(t)
b.mu.Lock()
defer b.mu.Unlock()
if hasAppConnectorService(b.peerAPIServicesLocked()) {
t.Fatal("unexpected app connector service")
}
b.appConnector = appc.NewAppConnector(t.Logf, nil)
if !hasAppConnectorService(b.peerAPIServicesLocked()) {
t.Fatal("expected app connector service")
}
}
func TestRouteAdvertiser(t *testing.T) {
b := newTestBackend(t)
testPrefix := netip.MustParsePrefix("192.0.0.8/32")
@@ -1249,7 +1227,26 @@ func TestReconfigureAppConnector(t *testing.T) {
if !slices.Equal(b.appConnector.Domains().AsSlice(), want) {
t.Fatalf("got domains %v, want %v", b.appConnector.Domains(), want)
}
if v, _ := b.hostinfo.AppConnector.Get(); !v {
t.Fatalf("expected app connector service")
}
// disable the connector in order to assert that the service is removed
b.EditPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AppConnector: ipn.AppConnectorPrefs{
Advertise: false,
},
},
AppConnectorSet: true,
})
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
if b.appConnector != nil {
t.Fatal("expected no app connector")
}
if v, _ := b.hostinfo.AppConnector.Get(); v {
t.Fatalf("expected no app connector service")
}
}
func resolversEqual(t *testing.T, a, b []*dnstype.Resolver) bool {

View File

@@ -881,6 +881,13 @@ func (h *peerAPIHandler) replyToDNSQueries() bool {
// ourselves. As a proxy for autogroup:internet access, we see
// if we would've accepted a packet to 0.0.0.0:53. We treat
// the IP 0.0.0.0 as being "the internet".
//
// Because of the way that filter checks work, rules are only
// checked after ensuring the destination IP is part of the
// local set of IPs. An exit node has 0.0.0.0/0 so its fine,
// but an app connector explicitly adds 0.0.0.0/32 (and the
// IPv6 equivalent) to make this work (see updateFilterLocked
// in LocalBackend).
f := b.filterAtomic.Load()
if f == nil {
return false

View File

@@ -56,16 +56,17 @@ type serveHTTPContext struct {
DestPort uint16
}
// serveListener is the state of host-level net.Listen for a specific (Tailscale IP, serve port)
// localListener is the state of host-level net.Listen for a specific (Tailscale IP, port)
// combination. If there are two TailscaleIPs (v4 and v6) and three ports being served,
// then there will be six of these active and looping in their Run method.
//
// This is not used in userspace-networking mode.
//
// Most serve traffic is intercepted by netstack. This exists purely for connections
// from the machine itself, as that goes via the kernel, so we need to be in the
// kernel's listening/routing tables.
type serveListener struct {
// localListener is used by tailscale serve (TCP only) as well as the built-in web client.
// Most serve traffic and peer traffic for the web client are intercepted by netstack.
// This listener exists purely for connections from the machine itself, as that goes via the kernel,
// so we need to be in the kernel's listening/routing tables.
type localListener struct {
b *LocalBackend
ap netip.AddrPort
ctx context.Context // valid while listener is desired
@@ -73,24 +74,36 @@ type serveListener struct {
logf logger.Logf
bo *backoff.Backoff // for retrying failed Listen calls
handler func(net.Conn) error // handler for inbound connections
closeListener syncs.AtomicValue[func() error] // Listener's Close method, if any
}
func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort, logf logger.Logf) *serveListener {
func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort, logf logger.Logf) *localListener {
ctx, cancel := context.WithCancel(ctx)
return &serveListener{
return &localListener{
b: b,
ap: ap,
ctx: ctx,
cancel: cancel,
logf: logf,
handler: func(conn net.Conn) error {
srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort()
handler := b.tcpHandlerForServe(ap.Port(), srcAddr)
if handler == nil {
b.logf("[unexpected] local-serve: no handler for %v to port %v", srcAddr, ap.Port())
conn.Close()
return nil
}
return handler(conn)
},
bo: backoff.NewBackoff("serve-listener", logf, 30*time.Second),
}
}
// Close cancels the context and closes the listener, if any.
func (s *serveListener) Close() error {
func (s *localListener) Close() error {
s.cancel()
if close, ok := s.closeListener.LoadOk(); ok {
s.closeListener.Store(nil)
@@ -99,10 +112,10 @@ func (s *serveListener) Close() error {
return nil
}
// Run starts a net.Listen for the serveListener's address and port.
// Run starts a net.Listen for the localListener's address and port.
// If unable to listen, it retries with exponential backoff.
// Listen is retried until the context is canceled.
func (s *serveListener) Run() {
func (s *localListener) Run() {
for {
ip := s.ap.Addr()
ipStr := ip.String()
@@ -115,7 +128,7 @@ func (s *serveListener) Run() {
// a specific interface. Without this hook, the system
// chooses a default interface to bind to.
if err := initListenConfig(&lc, ip, s.b.prevIfState, s.b.dialer.TUNName()); err != nil {
s.logf("serve failed to init listen config %v, backing off: %v", s.ap, err)
s.logf("localListener failed to init listen config %v, backing off: %v", s.ap, err)
s.bo.BackOff(s.ctx, err)
continue
}
@@ -138,26 +151,26 @@ func (s *serveListener) Run() {
ln, err := lc.Listen(s.ctx, tcp4or6, net.JoinHostPort(ipStr, fmt.Sprint(s.ap.Port())))
if err != nil {
if s.shouldWarnAboutListenError(err) {
s.logf("serve failed to listen on %v, backing off: %v", s.ap, err)
s.logf("localListener failed to listen on %v, backing off: %v", s.ap, err)
}
s.bo.BackOff(s.ctx, err)
continue
}
s.closeListener.Store(ln.Close)
s.logf("serve listening on %v", s.ap)
err = s.handleServeListenersAccept(ln)
s.logf("listening on %v", s.ap)
err = s.handleListenersAccept(ln)
if s.ctx.Err() != nil {
// context canceled, we're done
return
}
if err != nil {
s.logf("serve listener accept error, retrying: %v", err)
s.logf("localListener accept error, retrying: %v", err)
}
}
}
func (s *serveListener) shouldWarnAboutListenError(err error) bool {
func (s *localListener) shouldWarnAboutListenError(err error) bool {
if !s.b.sys.NetMon.Get().InterfaceState().HasIP(s.ap.Addr()) {
// Machine likely doesn't have IPv6 enabled (or the IP is still being
// assigned). No need to warn. Notably, WSL2 (Issue 6303).
@@ -168,23 +181,17 @@ func (s *serveListener) shouldWarnAboutListenError(err error) bool {
return true
}
// handleServeListenersAccept accepts connections for the Listener. It calls the
// handleListenersAccept accepts connections for the Listener. It calls the
// handler in a new goroutine for each accepted connection. This is used to
// handle local "tailscale serve" traffic originating from the machine itself.
func (s *serveListener) handleServeListenersAccept(ln net.Listener) error {
// handle local "tailscale serve" and web client traffic originating from the
// machine itself.
func (s *localListener) handleListenersAccept(ln net.Listener) error {
for {
conn, err := ln.Accept()
if err != nil {
return err
}
srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort()
handler := s.b.tcpHandlerForServe(s.ap.Port(), srcAddr)
if handler == nil {
s.b.logf("[unexpected] local-serve: no handler for %v to port %v", srcAddr, s.ap.Port())
conn.Close()
continue
}
go handler(conn)
go s.handler(conn)
}
}

View File

@@ -6,14 +6,20 @@
package ipnlocal
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"time"
"tailscale.com/client/tailscale"
"tailscale.com/client/web"
"tailscale.com/logtail/backoff"
"tailscale.com/net/netutil"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
)
const webClientPort = web.ListenPort
@@ -73,6 +79,10 @@ func (b *LocalBackend) WebClientShutdown() {
b.mu.Lock()
server := b.webClient.server
b.webClient.server = nil
for ap, ln := range b.webClientListeners {
ln.Close()
delete(b.webClientListeners, ap)
}
b.mu.Unlock() // release lock before shutdown
if server != nil {
server.Shutdown()
@@ -88,3 +98,41 @@ func (b *LocalBackend) handleWebClientConn(c net.Conn) error {
s := http.Server{Handler: b.webClient.server}
return s.Serve(netutil.NewOneConnListener(c, nil))
}
// updateWebClientListenersLocked creates listeners on the web client port (5252)
// for each of the local device's Tailscale IP addresses. This is needed to properly
// route local traffic when using kernel networking mode.
func (b *LocalBackend) updateWebClientListenersLocked() {
if b.netMap == nil {
return
}
addrs := b.netMap.GetAddresses()
for i := range addrs.LenIter() {
addrPort := netip.AddrPortFrom(addrs.At(i).Addr(), webClientPort)
if _, ok := b.webClientListeners[addrPort]; ok {
continue // already listening
}
sl := b.newWebClientListener(context.Background(), addrPort, b.logf)
mak.Set(&b.webClientListeners, addrPort, sl)
go sl.Run()
}
}
// newWebClientListener returns a listener for local connections to the built-in web client
// used to manage this Tailscale instance.
func (b *LocalBackend) newWebClientListener(ctx context.Context, ap netip.AddrPort, logf logger.Logf) *localListener {
ctx, cancel := context.WithCancel(ctx)
return &localListener{
b: b,
ap: ap,
ctx: ctx,
cancel: cancel,
logf: logf,
handler: b.handleWebClientConn,
bo: backoff.NewBackoff("webclient-listener", logf, 30*time.Second),
}
}

View File

@@ -27,3 +27,4 @@ func (b *LocalBackend) WebClientShutdown() {}
func (b *LocalBackend) handleWebClientConn(c net.Conn) error {
return errors.New("not implemented")
}
func (b *LocalBackend) updateWebClientListenersLocked() {}

View File

@@ -202,7 +202,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
lah := localapi.NewHandler(lb, s.logf, s.netMon, s.backendLogID)
lah.PermitRead, lah.PermitWrite = s.localAPIPermissions(ci)
lah.PermitCert = s.connCanFetchCerts(ci)
lah.CallerIsLocalAdmin = s.connIsLocalAdmin(ci)
lah.ConnIdentity = ci
lah.ServeHTTP(w, r)
return
}
@@ -364,25 +364,6 @@ func (s *Server) connCanFetchCerts(ci *ipnauth.ConnIdentity) bool {
return false
}
// connIsLocalAdmin reports whether ci has administrative access to the local
// machine, for whatever that means with respect to the current OS.
//
// This returns true only on Windows machines when the client user is elevated.
// This is useful because, on Windows, tailscaled itself always runs with
// elevated rights: we want to avoid privilege escalation for certain mutative operations.
func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
tok, err := ci.WindowsToken()
if err != nil {
if !errors.Is(err, ipnauth.ErrNotImplemented) {
s.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err)
}
return false
}
defer tok.Close()
return tok.IsElevated()
}
// addActiveHTTPRequest adds c to the server's list of active HTTP requests.
//
// If the returned error may be of type inUseOtherUserError.

View File

@@ -22,6 +22,7 @@ import (
"tailscale.com/types/ptr"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/version"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TKAFilteredPeer
@@ -710,3 +711,25 @@ type DebugDERPRegionReport struct {
Warnings []string
Errors []string
}
type SelfUpdateStatus string
const (
UpdateFinished SelfUpdateStatus = "UpdateFinished"
UpdateInProgress SelfUpdateStatus = "UpdateInProgress"
UpdateFailed SelfUpdateStatus = "UpdateFailed"
)
type UpdateProgress struct {
Status SelfUpdateStatus `json:"status,omitempty"`
Message string `json:"message,omitempty"`
Version string `json:"version,omitempty"`
}
func NewUpdateProgress(ps SelfUpdateStatus, msg string) UpdateProgress {
return UpdateProgress{
Status: ps,
Message: msg,
Version: version.Short(),
}
}

View File

@@ -18,6 +18,7 @@ import (
"net/http/httputil"
"net/netip"
"net/url"
"os/exec"
"runtime"
"slices"
"strconv"
@@ -26,10 +27,12 @@ import (
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnstate"
"tailscale.com/logtail"
@@ -50,6 +53,7 @@ import (
"tailscale.com/util/httpm"
"tailscale.com/util/mak"
"tailscale.com/util/osdiag"
"tailscale.com/util/osuser"
"tailscale.com/util/rands"
"tailscale.com/version"
"tailscale.com/wgengine/magicsock"
@@ -71,6 +75,7 @@ var handler = map[string]localAPIHandler{
// without a trailing slash:
"bugreport": (*Handler).serveBugReport,
"check-ip-forwarding": (*Handler).serveCheckIPForwarding,
"check-udp-gro-forwarding": (*Handler).serveCheckUDPGROForwarding,
"check-prefs": (*Handler).serveCheckPrefs,
"component-debug-logging": (*Handler).serveComponentDebugLogging,
"debug": (*Handler).serveDebug,
@@ -121,6 +126,9 @@ var handler = map[string]localAPIHandler{
"watch-ipn-bus": (*Handler).serveWatchIPNBus,
"whois": (*Handler).serveWhoIs,
"query-feature": (*Handler).serveQueryFeature,
"update/check": (*Handler).serveUpdateCheck,
"update/install": (*Handler).serveUpdateInstall,
"update/progress": (*Handler).serveUpdateProgress,
}
var (
@@ -158,16 +166,12 @@ type Handler struct {
// cert fetching access.
PermitCert bool
// CallerIsLocalAdmin is whether the this handler is being invoked as a
// result of a LocalAPI call from a user who is a local admin of the current
// machine.
//
// As of 2023-10-26 it is only populated on Windows.
//
// It can be used to to restrict some LocalAPI operations which should only
// be run by an admin and not unprivileged users in a computing environment
// managed by IT admins.
CallerIsLocalAdmin bool
// ConnIdentity is the identity of the client connected to the Handler.
ConnIdentity *ipnauth.ConnIdentity
// Test-only override for connIsLocalAdmin method. If non-nil,
// connIsLocalAdmin returns this value.
testConnIsLocalAdmin *bool
b *ipnlocal.LocalBackend
logf logger.Logf
@@ -920,8 +924,8 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
// require a local admin when setting a path handler
// TODO: roll-up this Windows-specific check into either PermitWrite
// or a global admin escalation check.
if shouldDenyServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h) {
http.Error(w, "must be a Windows local admin to serve a path", http.StatusUnauthorized)
if err := authorizeServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
@@ -940,14 +944,106 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
}
}
func shouldDenyServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) bool {
if goos != "windows" {
return false
func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) error {
switch goos {
case "windows", "linux", "darwin":
default:
return nil
}
// Only check for local admin on tailscaled-on-mac (based on "sudo"
// permissions). On sandboxed variants (MacSys and AppStore), tailscaled
// cannot serve files outside of the sandbox and this check is not
// relevant.
if goos == "darwin" && version.IsSandboxedMacOS() {
return nil
}
if !configIn.HasPathHandler() {
return nil
}
if h.connIsLocalAdmin() {
return nil
}
switch goos {
case "windows":
return errors.New("must be a Windows local admin to serve a path")
case "linux", "darwin":
return errors.New("must be root, or be an operator and able to run 'sudo tailscale' to serve a path")
default:
// We filter goos at the start of the func, this default case
// should never happen.
panic("unreachable")
}
}
// connIsLocalAdmin reports whether the connected client has administrative
// access to the local machine, for whatever that means with respect to the
// current OS.
//
// This is useful because tailscaled itself always runs with elevated rights:
// we want to avoid privilege escalation for certain mutative operations.
func (h *Handler) connIsLocalAdmin() bool {
if h.testConnIsLocalAdmin != nil {
return *h.testConnIsLocalAdmin
}
if h.ConnIdentity == nil {
h.logf("[unexpected] missing ConnIdentity in LocalAPI Handler")
return false
}
switch runtime.GOOS {
case "windows":
tok, err := h.ConnIdentity.WindowsToken()
if err != nil {
if !errors.Is(err, ipnauth.ErrNotImplemented) {
h.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err)
}
return false
}
defer tok.Close()
return tok.IsElevated()
case "darwin":
// Unknown, or at least unchecked on sandboxed macOS variants. Err on
// the side of less permissions.
//
// authorizeServeConfigForGOOSAndUserContext should not call
// connIsLocalAdmin on sandboxed variants anyway.
if version.IsSandboxedMacOS() {
return false
}
// This is a standalone tailscaled setup, use the same logic as on
// Linux.
fallthrough
case "linux":
uid, ok := h.ConnIdentity.Creds().UserID()
if !ok {
return false
}
// root is always admin.
if uid == "0" {
return true
}
// if non-root, must be operator AND able to execute "sudo tailscale".
operatorUID := h.b.OperatorUserID()
if operatorUID != "" && uid != operatorUID {
return false
}
u, err := osuser.LookupByUID(uid)
if err != nil {
return false
}
// Short timeout just in case sudo hands for some reason.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "sudo", "--other-user="+u.Name, "--list", "tailscale").Run(); err != nil {
return false
}
return true
default:
return false
}
return !h.CallerIsLocalAdmin
}
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {
@@ -967,6 +1063,23 @@ func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request)
})
}
func (h *Handler) serveCheckUDPGROForwarding(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "UDP GRO forwarding check access denied", http.StatusForbidden)
return
}
var warning string
if err := h.b.CheckUDPGROForwarding(); err != nil {
warning = err.Error()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Warning string
}{
Warning: warning,
})
}
func (h *Handler) serveStatus(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "status access denied", http.StatusForbidden)
@@ -2309,6 +2422,75 @@ func (h *Handler) serveDebugLog(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// serveUpdateCheck returns the ClientVersion from Status, which contains
// information on whether an update is available, and if so, what version,
// *if* we support auto-updates on this platform. If we don't, this endpoint
// always returns a ClientVersion saying we're running the newest version.
// Effectively, it tells us whether serveUpdateInstall will be able to install
// an update for us.
func (h *Handler) serveUpdateCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
return
}
_, err := clientupdate.NewUpdater(clientupdate.Arguments{
ForAutoUpdate: true,
})
if err != nil {
// if we don't support auto-update, just say that we're up to date
if errors.Is(err, errors.ErrUnsupported) {
json.NewEncoder(w).Encode(tailcfg.ClientVersion{RunningLatest: true})
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
cv := h.b.StatusWithoutPeers().ClientVersion
// ipnstate.Status documentation notes that ClientVersion may be nil on some
// platforms where this information is unavailable. In that case, return a
// ClientVersion that says we're up to date, since we have no information on
// whether an update is possible.
if cv == nil {
cv = &tailcfg.ClientVersion{RunningLatest: true}
}
json.NewEncoder(w).Encode(cv)
}
// serveUpdateInstall sends a request to the LocalBackend to start a Tailscale
// self-update. A successful response does not indicate whether the update
// succeeded, only that the request was accepted. Clients should use
// serveUpdateProgress after pinging this endpoint to check how the update is
// going.
func (h *Handler) serveUpdateInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "only POST allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusAccepted)
go h.b.DoSelfUpdate()
}
// serveUpdateProgress returns the status of an in-progress Tailscale self-update.
// This is provided as a slice of ipnstate.UpdateProgress structs with various
// log messages in order from oldest to newest. If an update is not in progress,
// the returned slice will be empty.
func (h *Handler) serveUpdateProgress(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
return
}
ups := h.b.GetSelfUpdateProgress()
json.NewEncoder(w).Encode(ups)
}
var (
metricInvalidRequests = clientmetric.NewCounter("localapi_invalid_requests")

View File

@@ -156,23 +156,17 @@ func TestWhoIsJustIP(t *testing.T) {
}
func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
newHandler := func(connIsLocalAdmin bool) *Handler {
return &Handler{testConnIsLocalAdmin: &connIsLocalAdmin}
}
tests := []struct {
name string
goos string
configIn *ipn.ServeConfig
h *Handler
want bool
wantErr bool
}{
{
name: "linux",
goos: "linux",
configIn: &ipn.ServeConfig{},
h: &Handler{CallerIsLocalAdmin: false},
want: false,
},
{
name: "windows-not-path-handler",
goos: "windows",
name: "not-path-handler",
configIn: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
@@ -180,12 +174,11 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
}},
},
},
h: &Handler{CallerIsLocalAdmin: false},
want: false,
h: newHandler(false),
wantErr: false,
},
{
name: "windows-path-handler-admin",
goos: "windows",
name: "path-handler-admin",
configIn: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
@@ -193,12 +186,11 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
}},
},
},
h: &Handler{CallerIsLocalAdmin: true},
want: false,
h: newHandler(true),
wantErr: false,
},
{
name: "windows-path-handler-not-admin",
goos: "windows",
name: "path-handler-not-admin",
configIn: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
@@ -206,19 +198,36 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
}},
},
},
h: &Handler{CallerIsLocalAdmin: false},
want: true,
h: newHandler(false),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldDenyServeConfigForGOOSAndUserContext(tt.goos, tt.configIn, tt.h)
if got != tt.want {
t.Errorf("shouldDenyServeConfigForGOOSAndUserContext() got = %v, want %v", got, tt.want)
}
})
for _, goos := range []string{"linux", "windows", "darwin"} {
t.Run(goos+"-"+tt.name, func(t *testing.T) {
err := authorizeServeConfigForGOOSAndUserContext(goos, tt.configIn, tt.h)
gotErr := err != nil
if gotErr != tt.wantErr {
t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want error %v", err, tt.wantErr)
}
})
}
}
t.Run("other-goos", func(t *testing.T) {
configIn := &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: "/tmp"},
}},
},
}
h := newHandler(false)
err := authorizeServeConfigForGOOSAndUserContext("dos", configIn, h)
if err != nil {
t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want nil", err)
}
})
}
func TestServeWatchIPNBus(t *testing.T) {

View File

@@ -53,6 +53,11 @@ const (
// CurrentProfileStateKey is the key under which we store the current
// profile.
CurrentProfileStateKey = StateKey("_current-profile")
// TaildropReceivedKey is the key to indicate whether any taildrop file
// has ever been received (even if partially).
// Any non-empty value indicates that at least one file has been received.
TaildropReceivedKey = StateKey("_taildrop-received")
)
// CurrentProfileID returns the StateKey that stores the

View File

@@ -56,7 +56,7 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/LICENSE))
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/2f6748dc88e7/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/db7604d1aa90/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/3e8cd9d6bf63/LICENSE))
- [github.com/vishvananda/netlink/nl](https://pkg.go.dev/github.com/vishvananda/netlink/nl) ([Apache-2.0](https://github.com/vishvananda/netlink/blob/v1.2.1-beta.2/LICENSE))

View File

@@ -76,8 +76,8 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/f0b76a10a08e/LICENSE))
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/7bcd7bca7bc5/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/2f6748dc88e7/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/8a84ac6b1db2/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/db7604d1aa90/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/toqueteos/webbrowser](https://pkg.go.dev/github.com/toqueteos/webbrowser) ([MIT](https://github.com/toqueteos/webbrowser/blob/v1.2.0/LICENSE.md))
- [github.com/u-root/u-root/pkg/termios](https://pkg.go.dev/github.com/u-root/u-root/pkg/termios) ([BSD-3-Clause](https://github.com/u-root/u-root/blob/v0.11.0/LICENSE))

View File

@@ -10,7 +10,6 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.0.0/LICENSE))
- [github.com/Microsoft/go-winio](https://pkg.go.dev/github.com/Microsoft/go-winio) ([MIT](https://github.com/Microsoft/go-winio/blob/v0.6.1/LICENSE))
- [github.com/alexbrainman/sspi](https://pkg.go.dev/github.com/alexbrainman/sspi) ([BSD-3-Clause](https://github.com/alexbrainman/sspi/blob/1a75b4708caa/LICENSE))
- [github.com/apenwarr/fixconsole](https://pkg.go.dev/github.com/apenwarr/fixconsole) ([Apache-2.0](https://github.com/apenwarr/fixconsole/blob/5a9f6489cc29/LICENSE))
- [github.com/apenwarr/w32](https://pkg.go.dev/github.com/apenwarr/w32) ([BSD-3-Clause](https://github.com/apenwarr/w32/blob/aa00fece76ab/LICENSE))
@@ -35,6 +34,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/nfnt/resize](https://pkg.go.dev/github.com/nfnt/resize) ([ISC](https://github.com/nfnt/resize/blob/83c6a9932646/LICENSE))
- [github.com/peterbourgon/diskv](https://pkg.go.dev/github.com/peterbourgon/diskv) ([MIT](https://github.com/peterbourgon/diskv/blob/v2.0.1/LICENSE))
- [github.com/skip2/go-qrcode](https://pkg.go.dev/github.com/skip2/go-qrcode) ([MIT](https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE))
- [github.com/tailscale/go-winio](https://pkg.go.dev/github.com/tailscale/go-winio) ([MIT](https://github.com/tailscale/go-winio/blob/c4f33415bf55/LICENSE))
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
- [github.com/tailscale/walk](https://pkg.go.dev/github.com/tailscale/walk) ([BSD-3-Clause](https://github.com/tailscale/walk/blob/dff4ed649e49/LICENSE))
- [github.com/tailscale/win](https://pkg.go.dev/github.com/tailscale/win) ([BSD-3-Clause](https://github.com/tailscale/win/blob/84569fd814a9/LICENSE))

View File

@@ -735,6 +735,8 @@ func (l *Logger) Write(buf []byte) (int, error) {
if len(buf) == 0 {
return 0, nil
}
inLen := len(buf) // length as provided to us, before modifications to downstream writers
level, buf := parseAndRemoveLogLevel(buf)
if l.stderr != nil && l.stderr != io.Discard && int64(level) <= atomic.LoadInt64(&l.stderrLevel) {
if buf[len(buf)-1] == '\n' {
@@ -752,7 +754,7 @@ func (l *Logger) Write(buf []byte) (int, error) {
b := l.encodeLocked(buf, level)
_, err := l.sendLocked(b)
return len(buf), err
return inLen, err
}
var (

View File

@@ -379,3 +379,30 @@ func TestEncode(t *testing.T) {
}
}
}
// Test that even if Logger.Write modifies the input buffer, we still return the
// length of the input buffer, not what we shrank it down to. Otherwise the
// caller will think we did a short write, violating the io.Writer contract.
func TestLoggerWriteResult(t *testing.T) {
buf := NewMemoryBuffer(100)
lg := &Logger{
clock: tstest.NewClock(tstest.ClockOpts{Start: time.Unix(123, 0)}),
buffer: buf,
}
const in = "[v1] foo"
n, err := lg.Write([]byte(in))
if err != nil {
t.Fatal(err)
}
if got, want := n, len(in); got != want {
t.Errorf("Write = %v; want %v", got, want)
}
back, err := buf.TryReadLine()
if err != nil {
t.Fatal(err)
}
if got, want := string(back), `{"logtail": {"client_time": "1970-01-01T00:02:03Z"}, "v":1,"text": "foo"}`+"\n"; got != want {
t.Errorf("mismatch.\n got: %#q\nwant: %#q", back, want)
}
}

View File

@@ -0,0 +1,5 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package netkernelconf contains code for checking kernel netdev config.
package netkernelconf

View File

@@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package netkernelconf
// CheckUDPGROForwarding is unimplemented for non-Linux platforms. Refer to the
// docstring in _linux.go.
func CheckUDPGROForwarding(tunInterface, defaultRouteInterface string) (warn, err error) {
return nil, nil
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package netkernelconf
import (
"fmt"
"github.com/safchain/ethtool"
)
// CheckUDPGROForwarding checks if the machine is optimally configured to
// forward UDP packets between the default route and Tailscale TUN interfaces.
// It returns a non-nil warn in the case that the configuration is suboptimal.
// It returns a non-nil err in the case that an error is encountered while
// performing the check.
func CheckUDPGROForwarding(tunInterface, defaultRouteInterface string) (warn, err error) {
const txFeature = "tx-udp-segmentation"
const rxWantFeature = "rx-udp-gro-forwarding"
const rxDoNotWantFeature = "rx-gro-list"
const kbLink = "\nSee https://tailscale.com/s/ethtool-config-udp-gro"
errWithPrefix := func(format string, a ...any) error {
const errPrefix = "couldn't check system's UDP GRO forwarding configuration, "
return fmt.Errorf(errPrefix+format, a...)
}
e, err := ethtool.NewEthtool()
if err != nil {
return nil, errWithPrefix("failed to init ethtool: %v", err)
}
defer e.Close()
tunFeatures, err := e.Features(tunInterface)
if err != nil {
return nil, errWithPrefix("failed to retrieve TUN device features: %v", err)
}
if !tunFeatures[txFeature] {
// if txFeature is disabled/nonexistent on the TUN then UDP GRO
// forwarding doesn't matter, we won't be taking advantage of it.
return nil, nil
}
defaultFeatures, err := e.Features(defaultRouteInterface)
if err != nil {
return nil, errWithPrefix("failed to retrieve default route interface features: %v", err)
}
defaultHasRxWant, ok := defaultFeatures[rxWantFeature]
if !ok {
// unlikely the feature is nonexistant with txFeature in the TUN driver
// being added to the kernel later than rxWantFeature, but let's be sure
return nil, nil
}
if !defaultHasRxWant || defaultFeatures[rxDoNotWantFeature] {
return fmt.Errorf("UDP GRO forwarding is suboptimally configured on %s, UDP forwarding throughput capability will increase with a configuration change.%s", defaultRouteInterface, kbLink), nil
}
return nil, nil
}

View File

@@ -8,13 +8,11 @@
package posture
import (
"errors"
"fmt"
"strings"
"github.com/digitalocean/go-smbios/smbios"
"tailscale.com/types/logger"
"tailscale.com/util/multierr"
)
// getByteFromSmbiosStructure retrieves a 8-bit unsigned integer at the given specOffset.
@@ -31,17 +29,17 @@ func getByteFromSmbiosStructure(s *smbios.Structure, specOffset int) uint8 {
// getStringFromSmbiosStructure retrieves a string at the given specOffset.
// Returns an empty string if no string was present.
func getStringFromSmbiosStructure(s *smbios.Structure, specOffset int) (string, error) {
func getStringFromSmbiosStructure(s *smbios.Structure, specOffset int) string {
index := getByteFromSmbiosStructure(s, specOffset)
if index == 0 || int(index) > len(s.Strings) {
return "", errors.New("specified offset does not exist in smbios structure")
return ""
}
str := s.Strings[index-1]
trimmed := strings.TrimSpace(str)
return trimmed, nil
return trimmed
}
// Product Table (Type 1) structure
@@ -71,31 +69,6 @@ func init() {
validTables = append(validTables, table)
}
numOfTables = len(validTables)
}
// serialFromSmbiosStructure extracts a serial number from a product,
// baseboard or chassis SMBIOS table.
func serialFromSmbiosStructure(s *smbios.Structure) (string, error) {
id := s.Header.Type
if (id != productID) && (id != baseboardID) && (id != chassisID) {
return "", fmt.Errorf(
"cannot get serial table type %d, supported tables are %v",
id,
validTables,
)
}
serial, err := getStringFromSmbiosStructure(s, serialNumberOffset)
if err != nil {
return "", fmt.Errorf(
"failed to get serial from %s table: %w",
idToTableName[int(s.Header.Type)],
err,
)
}
return serial, nil
}
func GetSerialNumbers(logf logger.Logf) ([]string, error) {
@@ -114,23 +87,18 @@ func GetSerialNumbers(logf logger.Logf) ([]string, error) {
}
serials := make([]string, 0, numOfTables)
errs := make([]error, 0, numOfTables)
for _, s := range ss {
switch s.Header.Type {
case productID, baseboardID, chassisID:
serial, err := serialFromSmbiosStructure(s)
if err != nil {
errs = append(errs, err)
continue
}
serial := getStringFromSmbiosStructure(s, serialNumberOffset)
serials = append(serials, serial)
if serial != "" {
serials = append(serials, serial)
}
}
}
err = multierr.New(errs...)
// if there were no serial numbers, check if any errors were
// returned and combine them.
if len(serials) == 0 && err != nil {

View File

@@ -8,7 +8,7 @@
// solaris: currently unsupported by go-smbios:
// https://github.com/digitalocean/go-smbios/pull/21
//go:build ios || android || solaris || plan9 || js || wasm || (darwin && !cgo)
//go:build ios || android || solaris || plan9 || js || wasm || (darwin && !cgo) || tamago
package posture

View File

@@ -88,7 +88,13 @@ func (d *derpProber) ProbeMap(ctx context.Context) error {
wantProbes[n] = true
if d.probes[n] == nil {
log.Printf("adding DERP TLS probe for %s (%s)", server.Name, region.RegionName)
d.probes[n] = d.p.Run(n, d.tlsInterval, labels, d.tlsProbeFn(server.HostName+":443"))
derpPort := 443
if server.DERPPort != 0 {
derpPort = server.DERPPort
}
d.probes[n] = d.p.Run(n, d.tlsInterval, labels, d.tlsProbeFn(fmt.Sprintf("%s:%d", server.HostName, derpPort)))
}
for idx, ipStr := range []string{server.IPv6, server.IPv4} {

View File

@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-ynBPVRKWPcoEbbZ/atvO6VdjHVwhnWcugSD3RGJA34E=
# nix-direnv cache busting line: sha256-/kuu7DKPklMZOvYqJpsOp3TeDG9KDEET4U0G+sq+4qY=

View File

@@ -465,6 +465,12 @@ func (ss *sshSession) launchProcess() error {
ss.logf("starting non-pty command: %+v", cmd.Args)
return ss.startWithStdPipes()
}
if sshDisablePTY() {
ss.logf("pty support disabled by envknob")
return errors.New("pty support disabled by envknob")
}
ss.ptyReq = &ptyReq
pty, tty, err := ss.startWithPTY()
if err != nil {

View File

@@ -49,7 +49,10 @@ import (
)
var (
sshVerboseLogging = envknob.RegisterBool("TS_DEBUG_SSH_VLOG")
sshVerboseLogging = envknob.RegisterBool("TS_DEBUG_SSH_VLOG")
sshDisableSFTP = envknob.RegisterBool("TS_SSH_DISABLE_SFTP")
sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING")
sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY")
)
const (
@@ -473,6 +476,9 @@ func (srv *server) newConn() (*conn, error) {
// to the specified host and port.
// TODO(bradfitz/maisem): should we have more checks on host/port?
func (c *conn) mayReversePortForwardTo(ctx ssh.Context, destinationHost string, destinationPort uint32) bool {
if sshDisableForwarding() {
return false
}
if c.finalAction != nil && c.finalAction.AllowRemotePortForwarding {
metricRemotePortForward.Add(1)
return true
@@ -484,6 +490,9 @@ func (c *conn) mayReversePortForwardTo(ctx ssh.Context, destinationHost string,
// to the specified host and port.
// TODO(bradfitz/maisem): should we have more checks on host/port?
func (c *conn) mayForwardLocalPortTo(ctx ssh.Context, destinationHost string, destinationPort uint32) bool {
if sshDisableForwarding() {
return false
}
if c.finalAction != nil && c.finalAction.AllowLocalPortForwarding {
metricLocalPortForward.Add(1)
return true
@@ -712,8 +721,15 @@ func (srv *server) fetchPublicKeysURL(url string) ([]string, error) {
func (c *conn) handleSessionPostSSHAuth(s ssh.Session) {
// Do this check after auth, but before starting the session.
switch s.Subsystem() {
case "sftp", "":
case "sftp":
if sshDisableSFTP() {
fmt.Fprintf(s.Stderr(), "sftp disabled\r\n")
s.Exit(1)
return
}
metricSFTP.Add(1)
case "":
// Regular SSH session.
default:
fmt.Fprintf(s.Stderr(), "Unsupported subsystem %q\r\n", s.Subsystem())
s.Exit(1)
@@ -986,6 +1002,12 @@ func (ss *sshSession) handleSSHAgentForwarding(s ssh.Session, lu *userMeta) erro
if !ssh.AgentRequested(ss) || !ss.conn.finalAction.AllowAgentForwarding {
return nil
}
if sshDisableForwarding() {
// TODO(bradfitz): or do we want to return an error here instead so the user
// gets an error if they ran with ssh -A? But for now we just silently
// don't work, like the condition above.
return nil
}
ss.logf("ssh: agent forwarding requested")
ln, err := ssh.NewAgentListener()
if err != nil {
@@ -1891,7 +1913,7 @@ var (
metricTerminalFetchError = clientmetric.NewCounter("ssh_terminalaction_fetch_error")
metricHolds = clientmetric.NewCounter("ssh_holds")
metricPolicyChangeKick = clientmetric.NewCounter("ssh_policy_change_kick")
metricSFTP = clientmetric.NewCounter("ssh_sftp_requests")
metricSFTP = clientmetric.NewCounter("ssh_sftp_sessions")
metricLocalPortForward = clientmetric.NewCounter("ssh_local_port_forward_requests")
metricRemotePortForward = clientmetric.NewCounter("ssh_remote_port_forward_requests")
)

View File

@@ -6,10 +6,7 @@
package tailssh
import (
"context"
"errors"
"io"
"log"
"os"
"os/exec"
"os/user"
@@ -17,13 +14,12 @@ import (
"runtime"
"strconv"
"strings"
"time"
"unicode/utf8"
"go4.org/mem"
"tailscale.com/envknob"
"tailscale.com/hostinfo"
"tailscale.com/util/lineread"
"tailscale.com/util/osuser"
"tailscale.com/version/distro"
)
@@ -51,90 +47,11 @@ func (u *userMeta) GroupIds() ([]string, error) {
// userLookup is like os/user.Lookup but it returns a *userMeta wrapper
// around a *user.User with extra fields.
func userLookup(username string) (*userMeta, error) {
if runtime.GOOS != "linux" {
return userLookupStd(username)
}
// No getent on Gokrazy. So hard-code the login shell.
if distro.Get() == distro.Gokrazy {
um, err := userLookupStd(username)
if err != nil {
um = &userMeta{
User: user.User{
Uid: "0",
Gid: "0",
Username: "root",
Name: "Gokrazy",
HomeDir: "/",
},
}
}
um.loginShellCached = "/tmp/serial-busybox/ash"
return um, err
}
// On Linux, default to using "getent" to look up users so that
// even with static tailscaled binaries without cgo (as we distribute),
// we can still look up PAM/NSS users which the standard library's
// os/user without cgo won't get (because of no libc hooks).
// But if "getent" fails, userLookupGetent falls back to the standard
// library anyway.
return userLookupGetent(username)
}
func validUsername(uid string) bool {
maxUid := 32
if runtime.GOOS == "linux" {
maxUid = 256
}
if len(uid) > maxUid || len(uid) == 0 {
return false
}
for _, r := range uid {
if r < ' ' || r == 0x7f || r == utf8.RuneError { // TODO(bradfitz): more?
return false
}
}
return true
}
func userLookupGetent(username string) (*userMeta, error) {
// Do some basic validation before passing this string to "getent", even though
// getent should do its own validation.
if !validUsername(username) {
return nil, errors.New("invalid username")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "getent", "passwd", username).Output()
if err != nil {
log.Printf("error calling getent for user %q: %v", username, err)
return userLookupStd(username)
}
// output is "alice:x:1001:1001:Alice Smith,,,:/home/alice:/bin/bash"
f := strings.SplitN(strings.TrimSpace(string(out)), ":", 10)
for len(f) < 7 {
f = append(f, "")
}
um := &userMeta{
User: user.User{
Username: f[0],
Uid: f[2],
Gid: f[3],
Name: f[4],
HomeDir: f[5],
},
loginShellCached: f[6],
}
return um, nil
}
func userLookupStd(username string) (*userMeta, error) {
u, err := user.Lookup(username)
u, s, err := osuser.LookupByUsernameWithShell(username)
if err != nil {
return nil, err
}
return &userMeta{User: *u}, nil
return &userMeta{User: *u, loginShellCached: s}, nil
}
func (u *userMeta) LoginShell() string {

View File

@@ -5,6 +5,8 @@
package tailcfg
import "net/netip"
// C2NSSHUsernamesRequest is the request for the /ssh/usernames.
// A GET request without a request body is equivalent to the zero value of this type.
// Otherwise, a POST request with a JSON-encoded request body is expected.
@@ -64,3 +66,12 @@ type C2NPostureIdentityResponse struct {
// device posture collection.
PostureDisabled bool `json:",omitempty"`
}
// C2NAppConnectorDomainRoutesResponse contains a map of domains to
// slice of addresses, indicating what IP addresses have been resolved
// for each domain.
type C2NAppConnectorDomainRoutesResponse struct {
// Domains is a map of lower case domain names with no trailing dot,
// to a list of resolved IP addresses.
Domains map[string][]netip.Addr
}

View File

@@ -624,12 +624,11 @@ func (h *Hostinfo) CheckRequestTags() error {
type ServiceProto string
const (
TCP = ServiceProto("tcp")
UDP = ServiceProto("udp")
PeerAPI4 = ServiceProto("peerapi4")
PeerAPI6 = ServiceProto("peerapi6")
PeerAPIDNS = ServiceProto("peerapi-dns-proxy")
AppConnector = ServiceProto("app-connector")
TCP = ServiceProto("tcp")
UDP = ServiceProto("udp")
PeerAPI4 = ServiceProto("peerapi4")
PeerAPI6 = ServiceProto("peerapi6")
PeerAPIDNS = ServiceProto("peerapi-dns-proxy")
)
// Service represents a service running on a node.
@@ -650,9 +649,6 @@ type Service struct {
// being a DNS proxy (when the node is an exit
// node). For this service, the Port number is really
// the version number of the service.
// * "app-connector": the local app-connector service is
// available. For this service, the Port number is
// really the version number of the service.
Proto ServiceProto
// Port is the port number.
@@ -748,6 +744,7 @@ type Hostinfo struct {
Cloud string `json:",omitempty"`
Userspace opt.Bool `json:",omitempty"` // if the client is running in userspace (netstack) mode
UserspaceRouter opt.Bool `json:",omitempty"` // if the client's subnet router is running in userspace (netstack) mode
AppConnector opt.Bool `json:",omitempty"` // if the client is running the app-connector service
// Location represents geographical location data about a
// Tailscale host. Location is optional and only set if
@@ -2126,6 +2123,10 @@ const (
// fixed port.
NodeAttrRandomizeClientPort NodeCapability = "randomize-client-port"
// NodeAttrSilentDisco makes the client suppress disco heartbeats to its
// peers.
NodeAttrSilentDisco NodeCapability = "silent-disco"
// NodeAttrOneCGNATEnable makes the client prefer one big CGNAT /10 route
// rather than a /32 per peer. At most one of this or
// NodeAttrOneCGNATDisable may be set; if neither are, it's automatic.

View File

@@ -177,6 +177,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
Cloud string
Userspace opt.Bool
UserspaceRouter opt.Bool
AppConnector opt.Bool
Location *Location
}{})

View File

@@ -17,6 +17,7 @@ import (
. "tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/opt"
"tailscale.com/types/ptr"
"tailscale.com/util/must"
)
@@ -64,6 +65,7 @@ func TestHostinfoEqual(t *testing.T) {
"Cloud",
"Userspace",
"UserspaceRouter",
"AppConnector",
"Location",
}
if have := fieldsOf(reflect.TypeOf(Hostinfo{})); !reflect.DeepEqual(have, hiHandles) {
@@ -228,6 +230,16 @@ func TestHostinfoEqual(t *testing.T) {
&Hostinfo{App: "golink"},
true,
},
{
&Hostinfo{AppConnector: opt.Bool("true")},
&Hostinfo{AppConnector: opt.Bool("true")},
true,
},
{
&Hostinfo{AppConnector: opt.Bool("true")},
&Hostinfo{AppConnector: opt.Bool("false")},
false,
},
}
for i, tt := range tests {
got := tt.a.Equal(tt.b)

View File

@@ -317,6 +317,7 @@ func (v HostinfoView) SSH_HostKeys() views.Slice[string] { return views.Sli
func (v HostinfoView) Cloud() string { return v.ж.Cloud }
func (v HostinfoView) Userspace() opt.Bool { return v.ж.Userspace }
func (v HostinfoView) UserspaceRouter() opt.Bool { return v.ж.UserspaceRouter }
func (v HostinfoView) AppConnector() opt.Bool { return v.ж.AppConnector }
func (v HostinfoView) Location() *Location {
if v.ж.Location == nil {
return nil
@@ -363,6 +364,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
Cloud string
Userspace opt.Bool
UserspaceRouter opt.Bool
AppConnector opt.Bool
Location *Location
}{})

View File

@@ -13,6 +13,7 @@ import (
"sync"
"time"
"tailscale.com/ipn"
"tailscale.com/syncs"
"tailscale.com/tstime"
"tailscale.com/types/logger"
@@ -52,13 +53,24 @@ func (d *fileDeleter) Init(m *Manager, eventHook func(string)) {
d.dir = m.opts.Dir
d.event = eventHook
// From a cold-start, load the list of partial and deleted files.
d.byName = make(map[string]*list.Element)
d.emptySignal = make(chan struct{})
d.shutdownCtx, d.shutdown = context.WithCancel(context.Background())
// From a cold-start, load the list of partial and deleted files.
//
// Only run this if we have ever received at least one file
// to avoid ever touching the taildrop directory on systems (e.g., MacOS)
// that pop up a security dialog window upon first access.
if m.opts.State == nil {
return
}
if b, _ := m.opts.State.ReadState(ipn.TaildropReceivedKey); len(b) == 0 {
return
}
d.group.Go(func() {
d.event("start init")
defer d.event("end init")
d.event("start full-scan")
defer d.event("end full-scan")
rangeDir(d.dir, func(de fs.DirEntry) bool {
switch {
case d.shutdownCtx.Err() != nil:

View File

@@ -11,6 +11,8 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/util/must"
@@ -72,6 +74,8 @@ func TestDeleter(t *testing.T) {
m.opts.Logf = t.Logf
m.opts.Clock = tstime.DefaultClock{Clock: clock}
m.opts.Dir = dir
m.opts.State = must.Get(mem.New(nil, ""))
must.Do(m.opts.State.WriteState(ipn.TaildropReceivedKey, []byte{1}))
fd.Init(&m, eventHook)
defer fd.Shutdown()
insert := func(name string) {
@@ -85,8 +89,8 @@ func TestDeleter(t *testing.T) {
fd.Remove(name)
}
checkEvents("start init")
checkEvents("end init", "start waitAndDelete")
checkEvents("start full-scan")
checkEvents("end full-scan", "start waitAndDelete")
checkDirectory("foo.partial", "bar.partial", "buzz.deleted")
advance(deleteDelay / 2)
@@ -134,3 +138,15 @@ func TestDeleter(t *testing.T) {
remove("wuzz.partial")
checkEvents("end waitAndDelete")
}
// Test that the asynchronous full scan of the taildrop directory does not occur
// on a cold start if taildrop has never received any files.
func TestDeleterInitWithoutTaildrop(t *testing.T) {
var m Manager
var fd fileDeleter
m.opts.Logf = t.Logf
m.opts.Dir = t.TempDir()
m.opts.State = must.Get(mem.New(nil, ""))
fd.Init(&m, func(event string) { t.Errorf("unexpected event: %v", event) })
fd.Shutdown()
}

View File

@@ -13,6 +13,7 @@ import (
"time"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/tstime"
"tailscale.com/version/distro"
)
@@ -136,6 +137,17 @@ func (m *Manager) PutFile(id ClientID, baseName string, r io.Reader, offset, len
}()
inFile.w = f
// Record that we have started to receive at least one file.
// This is used by the deleter upon a cold-start to scan the directory
// for any files that need to be deleted.
if m.opts.State != nil {
if b, _ := m.opts.State.ReadState(ipn.TaildropReceivedKey); len(b) == 0 {
if err := m.opts.State.WriteState(ipn.TaildropReceivedKey, []byte{1}); err != nil {
m.opts.Logf("WriteState error: %v", err) // non-fatal error
}
}
}
// A positive offset implies that we are resuming an existing file.
// Seek to the appropriate offset and truncate the file.
if offset != 0 {

View File

@@ -67,8 +67,9 @@ func (id ClientID) partialSuffix() string {
// ManagerOptions are options to configure the [Manager].
type ManagerOptions struct {
Logf logger.Logf
Clock tstime.DefaultClock
Logf logger.Logf // may be nil
Clock tstime.DefaultClock // may be nil
State ipn.StateStore // may be nil
// Dir is the directory to store received files.
// This main either be the final location for the files

View File

@@ -1009,6 +1009,9 @@ func (s *Server) ListenFunnel(network, addr string, opts ...FunnelOption) (net.L
if srvConfig == nil {
srvConfig = &ipn.ServeConfig{}
}
if len(st.CertDomains) == 0 {
return nil, errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https")
}
domain := st.CertDomains[0]
hp := ipn.HostPort(domain + ":" + portStr)
if !srvConfig.AllowFunnel[hp] {

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BSD-3-Clause
// Package appcfg contains an experimental configuration structure for
// "tailscale.com/app-connector" capmap extensions.
// "tailscale.com/app-connectors" capmap extensions.
package appctype
import (

139
util/osuser/user.go Normal file
View File

@@ -0,0 +1,139 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package osuser implements OS user lookup. It's a wrapper around os/user that
// works on non-cgo builds.
package osuser
import (
"context"
"errors"
"log"
"os/exec"
"os/user"
"runtime"
"strings"
"time"
"unicode/utf8"
"tailscale.com/version/distro"
)
// LookupByUIDWithShell is like os/user.LookupId but handles a few edge cases
// like gokrazy and non-cgo lookups, and returns the user shell. The user shell
// lookup is best-effort and may be empty.
func LookupByUIDWithShell(uid string) (u *user.User, shell string, err error) {
return lookup(uid, user.LookupId, true)
}
// LookupByUsernameWithShell is like os/user.Lookup but handles a few edge
// cases like gokrazy and non-cgo lookups, and returns the user shell. The user
// shell lookup is best-effort and may be empty.
func LookupByUsernameWithShell(username string) (u *user.User, shell string, err error) {
return lookup(username, user.Lookup, true)
}
// LookupByUID is like os/user.LookupId but handles a few edge cases like
// gokrazy and non-cgo lookups.
func LookupByUID(uid string) (*user.User, error) {
u, _, err := lookup(uid, user.LookupId, false)
return u, err
}
// LookupByUsername is like os/user.Lookup but handles a few edge cases like
// gokrazy and non-cgo lookups.
func LookupByUsername(username string) (*user.User, error) {
u, _, err := lookup(username, user.Lookup, false)
return u, err
}
// lookupStd is either user.Lookup or user.LookupId.
type lookupStd func(string) (*user.User, error)
func lookup(usernameOrUID string, std lookupStd, wantShell bool) (*user.User, string, error) {
// TODO(awly): we should use genet on more platforms, like FreeBSD.
if runtime.GOOS != "linux" {
u, err := std(usernameOrUID)
return u, "", err
}
// No getent on Gokrazy. So hard-code the login shell.
if distro.Get() == distro.Gokrazy {
var shell string
if wantShell {
shell = "/tmp/serial-busybox/ash"
}
u, err := std(usernameOrUID)
if err != nil {
return &user.User{
Uid: "0",
Gid: "0",
Username: "root",
Name: "Gokrazy",
HomeDir: "/",
}, shell, nil
}
return u, shell, nil
}
// Start with getent if caller wants to get the user shell.
if wantShell {
return userLookupGetent(usernameOrUID, std)
}
// If shell is not required, try os/user.Lookup* first and only use getent
// if that fails. This avoids spawning a child process when os/user lookup
// succeeds.
if u, err := std(usernameOrUID); err == nil {
return u, "", nil
}
return userLookupGetent(usernameOrUID, std)
}
func checkGetentInput(usernameOrUID string) bool {
maxUid := 32
if runtime.GOOS == "linux" {
maxUid = 256
}
if len(usernameOrUID) > maxUid || len(usernameOrUID) == 0 {
return false
}
for _, r := range usernameOrUID {
if r < ' ' || r == 0x7f || r == utf8.RuneError { // TODO(bradfitz): more?
return false
}
}
return true
}
// userLookupGetent uses "getent" to look up users so that even with static
// tailscaled binaries without cgo (as we distribute), we can still look up
// PAM/NSS users which the standard library's os/user without cgo won't get
// (because of no libc hooks). If "getent" fails, userLookupGetent falls back
// to the standard library.
func userLookupGetent(usernameOrUID string, std lookupStd) (*user.User, string, error) {
// Do some basic validation before passing this string to "getent", even though
// getent should do its own validation.
if !checkGetentInput(usernameOrUID) {
return nil, "", errors.New("invalid username or UID")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "getent", "passwd", usernameOrUID).Output()
if err != nil {
log.Printf("error calling getent for user %q: %v", usernameOrUID, err)
u, err := std(usernameOrUID)
return u, "", err
}
// output is "alice:x:1001:1001:Alice Smith,,,:/home/alice:/bin/bash"
f := strings.SplitN(strings.TrimSpace(string(out)), ":", 10)
for len(f) < 7 {
f = append(f, "")
}
return &user.User{
Username: f[0],
Uid: f[2],
Gid: f[3],
Name: f[4],
HomeDir: f[5],
}, f[6], nil
}

View File

@@ -225,14 +225,32 @@ func isSIDValidPrincipal(uid string) bool {
}
}
// EnableCurrentThreadPrivilege enables the named privilege
// in the current thread access token.
func EnableCurrentThreadPrivilege(name string) error {
// EnableCurrentThreadPrivilege enables the named privilege in the current
// thread access token. The current goroutine is also locked to the OS thread
// (runtime.LockOSThread). Callers must call the returned disable function when
// done with the privileged task.
func EnableCurrentThreadPrivilege(name string) (disable func() error, err error) {
runtime.LockOSThread()
if err := windows.ImpersonateSelf(windows.SecurityImpersonation); err != nil {
runtime.UnlockOSThread()
return nil, err
}
disable = func() error {
defer runtime.UnlockOSThread()
return windows.RevertToSelf()
}
defer func() {
if err != nil {
disable()
}
}()
var t windows.Token
err := windows.OpenThreadToken(windows.CurrentThread(),
err = windows.OpenThreadToken(windows.CurrentThread(),
windows.TOKEN_QUERY|windows.TOKEN_ADJUST_PRIVILEGES, false, &t)
if err != nil {
return err
return nil, err
}
defer t.Close()
@@ -240,15 +258,15 @@ func EnableCurrentThreadPrivilege(name string) error {
privStr, err := syscall.UTF16PtrFromString(name)
if err != nil {
return err
return nil, err
}
err = windows.LookupPrivilegeValue(nil, privStr, &tp.Privileges[0].Luid)
if err != nil {
return err
return nil, err
}
tp.PrivilegeCount = 1
tp.Privileges[0].Attributes = windows.SE_PRIVILEGE_ENABLED
return windows.AdjustTokenPrivileges(t, false, &tp, 0, nil, nil)
return disable, windows.AdjustTokenPrivileges(t, false, &tp, 0, nil, nil)
}
// StartProcessAsChild starts exePath process as a child of parentPID.
@@ -256,16 +274,7 @@ func EnableCurrentThreadPrivilege(name string) error {
// the new process, along with any optional environment variables in extraEnv.
func StartProcessAsChild(parentPID uint32, exePath string, extraEnv []string) error {
// The rest of this function requires SeDebugPrivilege to be held.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
err := windows.ImpersonateSelf(windows.SecurityImpersonation)
if err != nil {
return err
}
defer windows.RevertToSelf()
//
// According to https://docs.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights
//
// ... To open a handle to another process and obtain full access rights,
@@ -277,10 +286,11 @@ func StartProcessAsChild(parentPID uint32, exePath string, extraEnv []string) er
//
// TODO: try look for something less than SeDebugPrivilege
err = EnableCurrentThreadPrivilege("SeDebugPrivilege")
disableSeDebug, err := EnableCurrentThreadPrivilege("SeDebugPrivilege")
if err != nil {
return err
}
defer disableSeDebug()
ph, err := windows.OpenProcess(
windows.PROCESS_CREATE_PROCESS|windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_DUP_HANDLE,

View File

@@ -440,6 +440,13 @@ func (de *endpoint) heartbeat() {
de.heartBeatTimer = time.AfterFunc(heartbeatInterval, de.heartbeat)
}
// setHeartbeatDisabled sets heartbeatDisabled to the provided value.
func (de *endpoint) setHeartbeatDisabled(v bool) {
de.mu.Lock()
defer de.mu.Unlock()
de.heartbeatDisabled = v
}
// wantFullPingLocked reports whether we should ping to all our peers looking for
// a better path.
//
@@ -629,7 +636,7 @@ const discoPingSize = len(disco.Magic) + key.DiscoPublicRawLen + disco.NonceLen
// is the desired disco message size, including all disco headers but excluding IP/UDP
// headers.
//
// The caller (startPingLocked) should've already recorded the ping in
// The caller (startDiscoPingLocked) should've already recorded the ping in
// sentPing and set up the timer.
//
// The caller should use de.discoKey as the discoKey argument.
@@ -1335,7 +1342,11 @@ func (de *endpoint) stopAndReset() {
defer de.mu.Unlock()
if closing := de.c.closing.Load(); !closing {
de.c.logf("[v1] magicsock: doing cleanup for discovery key %s", de.discoShort())
if de.isWireguardOnly {
de.c.logf("[v1] magicsock: doing cleanup for wireguard key %s", de.publicKey.ShortString())
} else {
de.c.logf("[v1] magicsock: doing cleanup for discovery key %s", de.discoShort())
}
}
de.debugUpdates.Add(EndpointChange{
@@ -1359,8 +1370,10 @@ func (de *endpoint) resetLocked() {
for _, es := range de.endpointState {
es.lastPing = 0
}
for txid, sp := range de.sentPing {
de.removeSentDiscoPingLocked(txid, sp)
if !de.isWireguardOnly {
for txid, sp := range de.sentPing {
de.removeSentDiscoPingLocked(txid, sp)
}
}
}

View File

@@ -139,6 +139,8 @@ type Conn struct {
// logging.
noV4, noV6 atomic.Bool
silentDiscoOn atomic.Bool // whether silent disco is enabled
// noV4Send is whether IPv4 UDP is known to be unable to transmit
// at all. This could happen if the socket is in an invalid state
// (as can happen on darwin after a network link status change).
@@ -1797,10 +1799,31 @@ type debugFlags struct {
}
func (c *Conn) debugFlagsLocked() (f debugFlags) {
f.heartbeatDisabled = debugEnableSilentDisco() // TODO(bradfitz): controlknobs too, later
f.heartbeatDisabled = debugEnableSilentDisco() || c.silentDiscoOn.Load()
return
}
// SetSilentDisco toggles silent disco based on v.
func (c *Conn) SetSilentDisco(v bool) {
old := c.silentDiscoOn.Swap(v)
if old == v {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.peerMap.forEachEndpoint(func(ep *endpoint) {
ep.setHeartbeatDisabled(v)
})
}
// SilentDisco returns true if silent disco is enabled, otherwise false.
func (c *Conn) SilentDisco() bool {
c.mu.Lock()
defer c.mu.Unlock()
flags := c.debugFlagsLocked()
return flags.heartbeatDisabled
}
// SetNetworkMap is called when the control client gets a new network
// map from the control server. It must always be non-nil.
//