Compare commits

...

405 Commits

Author SHA1 Message Date
Marwan Sulaiman
8092eaed80 ipn/ipnlocal,others: add tailnet display name to user profile
This PR adds the new editable display name for a tailnet which
will be the preferred display in client UIs and CLIs when
fast user switching between profiles.

Updates #9286

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-12-01 03:04:35 -05:00
Marwan Sulaiman
b819f66eb1 tsweb: propagate RequestID via context and entire request
The recent addition of RequestID was only populated if the
HTTP Request had returned an error. This meant that the underlying
handler has no access to this request id and any logs it may have
emitted were impossible to correlate to that request id. Therefore,
this PR adds a middleware to generate request ids and pass them
through the request context. The tsweb.StdHandler automatically
populates this request id if the middleware is being used. Finally,
inner handlers can use the context to retrieve that same request id
and use it so that all logs and events can be correlated.

Updates #2549

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-11-30 15:00:29 -05:00
Juergen Knaack
c27aa9e7ff net/dns: fix darwin dns resolver files
putting each nameserver on one line in /etc/resolver/<domain>

fixes: #10134
Signed-off-by: Juergen Knaack <jk@jk-1.de>
2023-11-29 19:25:31 -08:00
Sonia Appasamy
cbd0b60743 client/web: remove ControlAdminURL override
Was setting this for testing, snuck into the merged version.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-29 18:34:12 -05:00
Sonia Appasamy
bcc9b44cb1 client/web: hide admin panel links for non-tailscale control servers
Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-29 16:51:46 -05:00
Claire Wang
8af503b0c5 syspolicy: add exit node related policies (#10172)
Adds policy keys ExitNodeID and ExitNodeIP.
Uses the policy keys to determine the exit node in preferences.
Fixes tailscale/corp#15683

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-11-29 16:48:25 -05:00
Sonia Appasamy
ecd1ccb917 client/web: add subnet routes view
Add UI view for mutating the node's advertised subnet routes.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-29 15:09:07 -05:00
Sonia Appasamy
7aa981ba49 client/web: remove duplicate WhoIs call
Fixes a TODO in web.authorizeRequest.

`getSession` calls `WhoIs` already. Call `getSession` earlier in
`authorizeRequest` so we can avoid the duplicate `WhoIs` check on
the same request.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-29 14:46:57 -05:00
Sonia Appasamy
bc4e303846 ipn/ipnstate: add AllowedIPs to PeerStatus
Adds AllowedIPs to PeerStatus, allowing for easier lookup of the
routes allowed to be routed to a node. Will be using the AllowedIPs
of the self node from the web client interface to display approval
status of advertised routes.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-29 14:35:30 -05:00
Andrew Lytvynov
ac4b416c5b cmd/tailscale,ipn/ipnlocal: pass available update as health message (#10420)
To be consistent with the formatting of other warnings, pass available
update health message instead of handling ClientVersion in he CLI.

Fixes #10312

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-29 09:55:39 -08:00
Will Norris
26db9775f8 client/web: skip check mode for non-tailscale.com control servers (#10413)
client/web: skip check mode for non-tailscale.com control servers

Only enforce check mode if the control server URL ends in
".tailscale.com".  This allows the web client to be used with headscale
(or other) control servers while we work with the project to add check
mode support (tracked in juanfont/headscale#1623).

Updates #10261

Co-authored-by: Sonia Appasamy <sonia@tailscale.com>
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
Signed-off-by: Will Norris <will@tailscale.com>
2023-11-29 08:44:48 -08:00
Sonia Appasamy
ab0e25beaa client/web: fix Vite dev server build error
6e30c9d1f added eslint to the web client. As a part of that change,
the existing yarn.lock file was removed and yarn install run to build
with a clean yarn dependencies set with latest versions. This caused
a change in the "vite-plugin-rewrite-all" package that fails at build
time with our existing vite config. This is a known bug with some
suggested fixes:
https://vitejs.dev/guide/troubleshooting.html#this-package-is-esm-only

Rather than editing our package.json type, this commit reverts back
the yarn.lock file to it's contents at the commit just before 6e30c9d1f
and then only runs yarn install to add the new eslint packages, rather
than installing the latest versions of all packages.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-29 10:54:05 -05:00
Tom DNetto
8a660a6513 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>
2023-11-28 16:08:13 -08:00
Sonia Appasamy
6e30c9d1fe client/web: add eslint
Add eslint to require stricter typescript rules, particularly around
required hook dependencies. This commit also updates any files that
were now throwing errors with eslint.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-28 17:06:33 -05:00
Andrew Lytvynov
5a9e935597 clientupdate: implement update for Unraid (#10344)
Use the [`plugin`
CLI](https://forums.unraid.net/topic/72240-solved-is-there-a-way-to-installuninstall-plugins-from-script/#comment-676870)
to fetch and apply the update.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-28 13:28:30 -08:00
Jordan Whited
5e861c3871 wgengine/netstack: disable RACK on Windows (#10402)
Updates #9707

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-11-28 12:12:32 -08:00
Sonia Appasamy
5f40b8a0bc scripts/check_license_headers: enforce license on ts/tsx files
Enforcing inclusion of our OSS license at the top of .ts and .tsx
files. Also updates any relevant files in the repo that were
previously missing the license comment. An additional `@license`
comment is added to client/web/src/index.tsx to preserve the
license in generated Javascript.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-28 13:57:09 -05:00
Jenny Zhang
09b4914916 tka: clarify field comment
Updates #cleanup
Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-11-27 18:35:33 -05:00
Denton Gentry
67f3b2a525 tsnet: add CapturePcap method for debugging
Application code can call the tsnet s.CapturePcap(filename) method
to write all packets, sent and received, to a pcap file. The cleartext
packets are written, outside the Wireguard tunnel. This is expected
to be useful for debugging.

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

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-11-27 12:46:28 -08:00
Sonia Appasamy
b247435d66 client/web: scroll exit node dropdown to top on search
When search input changes, reset the scroll to the top of the
dropdown list.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-27 12:43:10 -05:00
Jenny Zhang
d5d84f1a68 cmd/tailscale: also warn about IP forwarding if using CLI set
We warn users about IP forwarding being disabled when using
`--avertise-routes` in `tailscale up`, this adds the same warnings
to `tailscale set`.

Updates tailscale/corp#9968
Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-11-24 16:27:52 -05:00
Irbe Krumina
18ceb4e1f6 cmd/{containerboot,k8s-operator}: allow users to define tailnet egress target by FQDN (#10360)
* cmd/containerboot: proxy traffic to tailnet target defined by FQDN

Add a new Service annotation tailscale.com/tailnet-fqdn that
users can use to specify a tailnet target for which
an egress proxy should be deployed in the cluster.

Updates tailscale/tailscale#10280

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-24 16:24:48 +00:00
David Anderson
2a01df97b8 flake.nix: use vendorHash instead of vendorSha256
vendorSha256 is getting retired, and throws warning in builds.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-11-23 20:32:54 -08:00
Flakes Updater
6b395f6385 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-23 20:16:43 -08:00
Charlotte Brandhorst-Satzkorn
9e63bf5fd6 words: crikey! what a beauty of a list
If I have to add a tail, or a scale, mate, I will add it.

Updates tailscale/corp#14698

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-11-22 21:17:05 -08:00
Tom DNetto
611e0a5bcc appc,ipn/local: support wildcard when matching app-connectors
Updates: ENG-2453
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-11-22 14:47:44 -08:00
Jordan Whited
1af7f5b549 wgengine/magicsock: fix typo in Conn.handlePingLocked() (#10365)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-11-22 14:33:12 -08:00
Andrew Dunham
5aa7687b21 util/httpm: don't run test if .git doesn't exist
Updates #9635

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I9089200f9327605036c88fc12834acece0c11694
2023-11-22 12:09:59 -05:00
Claire Wang
afacf2e368 containerboot: Add TS_ACCEPT_ROUTES (#10176)
Fixes tailscale/corp#15596

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-11-22 11:45:44 -05:00
Gabriel Martinez
128d3ad1a9 cmd/k8s-operator: helm chart add missing keys (#10296)
* cmd/k8s-operator: add missing keys to Helm values file

Updates  #10182

Signed-off-by: Gabriel Martinez <gabrielmartinez@sisti.pt>
2023-11-22 11:02:54 +00:00
Jordan Whited
e1d0d26686 go.mod: bump wireguard-go (#10352)
This pulls in tailscale/wireguard-go@8cc8b8b and
tailscale/wireguard-go@cc193a0, which improve throughput and latency
under load.

Updates tailscale/corp#11061

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-11-21 11:21:39 -08:00
Cole Helbling
a8647b3c37 flake: fixup version embedding (#9997)
It looks like `gitCommitStamp` is the new "entrypoint" for setting this
information.

Fixes #9996.

Signed-off-by: Cole Helbling <cole.helbling@determinate.systems>
2023-11-21 12:49:34 -05:00
Percy Wegmann
17501ea31a ci: report test coverage to coveralls.io
This records test coverage for the amd64 no race tests and uploads the
results to coveralls.io.

Updates #cleanup

Signed-off-by: Ox Cart <ox.to.a.cart@gmail.com>
2023-11-21 09:08:37 -06:00
Irbe Krumina
66471710f8 cmd/k8s-operator: truncate long StatefulSet name prefixes (#10343)
Kubernetes can generate StatefulSet names that are too long and result in invalid Pod revision hash label values.
Calculate whether a StatefulSet name generated for a Service or Ingress
will be too long and if so, truncate it.

Updates tailscale/tailscale#10284

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-21 10:20:37 +00:00
Andrew Lytvynov
2c1f14d9e6 util/set: implement json.Marshaler/Unmarshaler (#10308)
Marshal as a JSON list instead of a map. Because set elements are
`comparable` and not `cmp.Ordered`, we cannot easily sort the items
before marshaling.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-20 08:00:31 -08:00
Irbe Krumina
dd8bc9ba03 cmd/k8s-operator: log user/group impersonated by apiserver proxy (#10334)
Updates tailscale/tailscale#10127

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-20 15:41:18 +00:00
Irbe Krumina
4f80f403be cmd/k8s-operator: fix chart syntax error (#10333)
Updates #9222

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-20 10:39:14 +00:00
License Updater
2fa219440b licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-11-18 16:39:05 -08:00
David Anderson
f867392970 cmd/tailscale/cli: add debug function to print the netmap
It's possible to do this with a combination of watch-ipn and jq, but looking
at the netmap while debugging is quite common, so it's nice to have a one-shot
command to get it.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-11-18 15:04:36 -08:00
David Anderson
fd22145b52 cmd/tailscale/cli: make 'debug watch-ipn' play nice with jq
jq doens't like non-json output in the json stream, and works more happily
when the input stream EOFs at some point. Move non-json words to stderr, and
add a parameter to stop watching and exit after some number of objects.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-11-18 14:34:59 -08:00
Ryan Petris
c4855fe0ea Fix Empty Resolver Set
Config.singleResolverSet returns true if all routes have the same resolvers,
even if the routes have no resolvers. If none of the routes have a specific
resolver, the default should be used instead. Therefore, check for more than
0 instead of nil.

Signed-off-by: Ryan Petris <ryan@petris.net>
2023-11-18 14:22:26 -08:00
Uri Gorelik
b88929edf8 Fix potential goroutine leak in syncs/watchdog.go
Depending on how the preemption will occur, in some scenarios sendc
would have blocked indefinitely even after cancelling the context.

Fixes #10315

Signed-off-by: Uri Gorelik <uri.gore@gmail.com>
2023-11-18 10:37:29 -08:00
Flakes Updater
e7cad78b00 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-17 18:55:53 -08:00
OSS Updater
fc8488fac0 go.mod: update web-client-prebuilt module
Signed-off-by: OSS Updater <noreply+oss-updater@tailscale.com>
2023-11-17 18:48:20 -08:00
Will Norris
42dc843a87 client/web: add advanced login options
This adds an expandable section of the login view to allow users to
specify an auth key and an alternate control URL.

Input and Collapsible components and accompanying styles were brought
over from the adminpanel.

Updates #10261

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-17 18:39:02 -08:00
Flakes Updater
f0613ab606 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-17 15:23:16 -08:00
OSS Updater
3402998c1c go.mod: update web-client-prebuilt module
Signed-off-by: OSS Updater <noreply+oss-updater@tailscale.com>
2023-11-17 17:34:41 -05:00
Sonia Appasamy
38ea8f8c9c client/web: add Inter font
Adds Inter font and uses it as the default for the web UI.
Creates a new /assets folder to house the /fonts, and moves /icons
to live here too.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-17 17:09:37 -05:00
Marwan Sulaiman
2dc0645368 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-17 17:00:11 -05:00
Sonia Appasamy
e75be017e4 client/web: add exit node selector
Add exit node selector (in full management client only) that allows
for advertising as an exit node, or selecting another exit node on
the Tailnet for use.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-17 16:45:33 -05:00
License Updater
0e27ec2cd9 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-11-17 12:22:59 -08:00
License Updater
4f05cf685e licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-11-17 12:22:13 -08:00
License Updater
05298f4336 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-11-17 12:21:49 -08:00
Will Norris
f880c77df0 client/web: split login from nodeUpdate
This creates a new /api/up endpoint which is exposed in the login
client, and is solely focused on logging in. Login has been removed from
the nodeUpdate endpoint.

This also adds support in the LoginClientView for a stopped node that
just needs to reconnect, but not necessarily reauthenticate.  This
follows the same pattern in `tailscale up` of just setting the
WantRunning user pref.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-17 12:12:31 -08:00
James Tucker
28684b0538 cmd/tailscale/cli: correct app connector help text in set
Updates tailscale/corp#15437
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-17 11:51:36 -08:00
Sonia Appasamy
980f1f28ce client/web: hide unimplemented links
Hiding links to unimplemented settings pages.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-17 14:24:46 -05:00
Brad Fitzpatrick
fb829ea7f1 control/controlclient: support incremental packet filter updates [capver 81]
Updates #10299

Change-Id: I87e4235c668a1db7de7ef1abc743f0beecb86d3d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-17 10:07:12 -08:00
Claire Wang
b8a2aedccd util/syspolicy: add caching handler (#10288)
Fixes tailscale/corp#15850
Co-authored-by: Adrian Dewhurst <adrian@tailscale.com>
Signed-off-by: Claire Wang <claire@tailscale.com>
2023-11-17 12:31:51 -05:00
Ox Cart
719ee4415e ssh/tailssh: use control server time instead of local time
This takes advantage of existing functionality in ipn/ipnlocal to adjust
the local clock based on periodic time signals from the control server.
This way, when checking things like SSHRule expirations, calculations are
protected incorrectly set local clocks.

Fixes tailscale/corp#15796

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2023-11-17 11:10:11 -06:00
Sonia Appasamy
bd534b971a {client/web},{ipn/ipnlocal}: replace localapi debug-web-client endpoint
This change removes the existing debug-web-client localapi endpoint
and replaces it with functions passed directly to the web.ServerOpts
when constructing a web.ManageServerMode client.

The debug-web-client endpoint previously handled making noise
requests to the control server via the /machine/webclient/ endpoints.
The noise requests must be made from tailscaled, which has the noise
connection open. But, now that the full client is served from
tailscaled, we no longer need to proxy this request over the localapi.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-16 18:32:52 -05:00
Brad Fitzpatrick
4d196c12d9 health: don't report a warning in DERP homeless mode
Updates #3363
Updates tailscale/corp#396

Change-Id: Ibfb0496821cb58a78399feb88d4206d81e95ca0f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-16 14:08:47 -08:00
Brad Fitzpatrick
cca27ef96a ipn/ipnlocal: add c2n method to check on TLS cert fetch status
So the control plane can delete TXT records more aggressively
after client's done with ACME fetch.

Updates tailscale/corp#15848

Change-Id: I4f1140305bee11ee3eee93d4fec3aef2bd6c5a7e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-16 14:08:38 -08:00
Irbe Krumina
664ebb14d9 cmd/containerboot: fix unclean shutdown (#10035)
* cmd/containerboot: shut down cleanly on SIGTERM

Make sure that tailscaled watcher returns when
SIGTERM is received and also that it shuts down
before tailscaled exits.

Updates tailscale/tailscale#10090

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-16 19:23:18 +00:00
Sonia Appasamy
7238586652 client/web: fix margins on login popover
Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-16 14:08:08 -05:00
Will Norris
5aa22ff3eb tsnet: add option to run integrated web client
Updates #10261

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-16 11:01:55 -08:00
Tyler Smalley
90eb5379f4 ipn/ipnlocal: log and don't return full file serve error (#10174)
Previously we would return the full error from Stat or Open, possibily exposing the full file path. This change will log the error and return the generic error message "an error occurred reading the file or directory".

Updates tailscale/corp#15485

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-11-16 10:53:40 -08:00
Sonia Appasamy
4f409012c5 client/web: when readonly, add check for TS connection
When the viewing user is accessing a webclient not over Tailscale,
they must connect over Tailscale before being able to log into the
full management client, which is served over TS. This change adds
a check that the user is able to access the node's tailscale IP.
If not able to, the signin button is disabled. We'll also be adding
Copy here to help explain to the user that they must connect to
Tailscale before proceeding.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-16 13:46:54 -05:00
Will Norris
33147c4591 .github: build gocross using regular GOPROXY settings
This `go get` action has been running very slowly, and I'm pretty sure
it's because we're building gocross on the first `./tool/go` run, and
because we've set `GOPROXY=direct`, it's going directly to GitHub to
fetch all of the gocross dependencies.

Updates #cleanup

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-16 09:46:48 -08:00
Flakes Updater
a3c11b87c6 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-16 09:20:21 -08:00
OSS Updater
146c4bacde go.mod: update web-client-prebuilt module
Signed-off-by: OSS Updater <noreply+oss-updater@tailscale.com>
2023-11-16 09:14:10 -08:00
Will Norris
d01fa857b1 client/web: allow login client to still run tailscale up
I don't believe this has ever worked, since we didn't allow POST
requests in the login client. But previously, we were primarily using
the legacy client, so it didn't really matter. Now that we've removed
the legacy client, we have no way to login.

This fixes the login client, allowing it to login, but it still needs to
be refactored to expose a dedicated login method, without exposing all
the node update functionality.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-16 07:30:04 -08:00
Brad Fitzpatrick
3bd382f369 wgengine/magicsock: add DERP homeless debug mode for testing
In DERP homeless mode, a DERP home connection is not sought or
maintained and the local node is not reachable.

Updates #3363
Updates tailscale/corp#396

Change-Id: Ibc30488ac2e3cfe4810733b96c2c9f10a51b8331
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-15 18:45:10 -08:00
Jordan Whited
2ff54f9d12 wgengine/magicsock: move trustBestAddrUntil forward on non-disco rx (#10274)
This is gated behind the silent disco control knob, which is still in
its infancy. Prior to this change disco pong reception was the only
event that could move trustBestAddrUntil forward, so even though we
weren't heartbeating, we would kick off discovery pings every
trustUDPAddrDuration and mirror to DERP.

Updates #540

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-11-15 16:30:50 -08:00
Maisem Ali
57129205e6 cmd/tailscaled: make tun mode default on gokrazy
Now that we have nftable support this works fine and force
it on gokrazy since 25a8daf405.

Updates gokrazy/gokrazy#209

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-11-15 15:36:42 -08:00
Sonia Appasamy
96ad9b6138 client/web: remove legacy-client-view.tsx
Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-15 18:12:13 -05:00
Sonia Appasamy
055394f3be ipn/ipnlocal: add mutex to webClient struct
Adds a new sync.Mutex field to the webClient struct, rather than
using the general LocalBackend mutex. Since webClientGetOrInit
(previously WebClientInit) gets called on every connection, we
want to avoid holding the lock on LocalBackend just to check if
the server is initialized.

Moves all web_client.go funcs over to using the webClient.mu field.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-15 17:57:48 -05:00
Maisem Ali
7d4221c295 cmd/tsidp: add start of OIDC Tailscale IdP
Updates #10263

Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I240bc9b5ecf2df6f92c45929d105fde66c06a860
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-15 14:27:39 -08:00
Sonia Appasamy
2dbd546766 client/web: remove DebugMode from GET /api/data
No longer using this! Readonly state fully managed via auth endpoint.
Also getting rid of old Legacy server mode.

A #cleanup

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-15 17:18:33 -05:00
Sonia Appasamy
6f7a1b51a8 ipn/ipnlocal: rename SetWebLocalClient to ConfigureWebClient
For consistency with the "WebClient" naming of the other functions
here. Also fixed a doc typo.

A #cleanup

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-15 16:51:28 -05:00
Naman Sood
d5c460e83c client/{tailscale,web}: add initial webUI frontend for self-updates (#10191)
Updates #10187.

Signed-off-by: Naman Sood <mail@nsood.in>
2023-11-15 16:04:44 -05:00
James Tucker
245ddb157b appc: fix DomainRoutes copy
The non-referential copy destination doesn't extend the map contents,
but also the read of a non-key is returning a zero value not bound to
the map contents in any way.

Updates tailscale/corp#15657

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-15 12:20:00 -08:00
Adrian Dewhurst
b8ac3c5191 util/syspolicy: add some additional policy keys
These policy keys are supported on Apple platforms in Swift code; in
order to support them on platforms using Go (e.g. Windows), they also
need to be recorded here.

This does not affect any code, it simply adds the constants for now.

Updates ENG-2240
Updates ENG-2127
Updates ENG-2133

Change-Id: I0aa9863a3641e5844479da3b162761452db1ef42
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2023-11-15 15:03:21 -05:00
Adrian Dewhurst
1ef5bd5381 util/osdiag, util/winutil: expose Windows policy key
The Windows base registry key is already exported but the policy key was
not. util/osdiag currently replicates the string rather than the
preferred approach of reusing the constant.

Updates #cleanup

Change-Id: I6c1c45337896c744059b85643da2364fb3f232f2
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2023-11-15 15:01:25 -05:00
Aaron Klotz
855f79fad7 cmd/tailscaled, util/winutil: changes to process and token APIs in winutil
This PR changes the internal getTokenInfo function to use generics.
I also removed our own implementations for obtaining a token's user
and primary group in favour of calling the ones now available in
x/sys/windows.

Furthermore, I added two new functions for working with tokens, logon
session IDs, and Terminal Services / RDP session IDs.

I modified our privilege enabling code to allow enabling of multiple
privileges via one single function call.

Finally, I added the ProcessImageName function and updated the code in
tailscaled_windows.go to use that instead of directly calling the
underlying API.

All of these changes will be utilized by subsequent PRs pertaining to
this issue.

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

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-11-15 12:07:19 -07:00
Will Norris
03e780e9af client/web: disable the "disable" button when disabled
We currently disable the exit-node drop down selector when the user is
in read-only mode, but we missed disabling the "Disable" button also.
Previously, it would display an error when clicked.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-15 11:05:59 -08:00
Will Norris
9b537f7c97 ipn: remove the preview-webclient node capability
Now that 1.54 has released, and the new web client will be included in
1.56, we can remove the need for the node capability. This means that
all 1.55 unstable builds, and then eventually the 1.56 build, will work
without setting the node capability.

The web client still requires the "webclient" user pref, so this does
NOT mean that the web client will be on by default for all devices.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-15 11:04:35 -08:00
Will Norris
303a1e86f5 cmd/tailscale: expose --webclient for all builds
This removes the dev/unstable build check for the --webclient flag on
`tailscale set`, so that it will be included in the next major stable
release (1.56)

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-15 11:02:16 -08:00
Andrew Dunham
e33bc64cff net/dnsfallback: add singleflight to recursive resolver
This prevents running more than one recursive resolution for the same
hostname in parallel, which can use excessive amounts of CPU when called
in a tight loop. Additionally, add tests that hit the network (when
run with a flag) to test the lookup behaviour.

Updates tailscale/corp#15261

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I39351e1d2a8782dd4c52cb04b3bd982eb651c81e
2023-11-15 13:57:49 -05:00
Denton Gentry
a40e918d63 VERSION.txt: this is v1.55.0
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-11-15 10:15:28 -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
Anton Tolchanov
ef6a6e94f1 derp/derphttp: use a getter method to read server key
To hold the mutex while accessing it.

Fixes #10122

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-11-06 14:58:55 +00:00
Brad Fitzpatrick
44c6909c92 control/controlclient: move watchdog out of mapSession
In prep for making mapSession's lifetime not be 1:1 with a single HTTP
response's lifetime, this moves the inactivity timer watchdog out of
mapSession and into the caller that owns the streaming HTTP response.

(This is admittedly closer to how it was prior to the mapSession type
existing, but that was before we connected some dots which were
impossible to even see before the mapSession type broke the code up.)

Updates #7175

Change-Id: Ia108dac84a4953db41cbd30e73b1de4a2a676c11
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-05 10:57:36 -08:00
Brad Fitzpatrick
c87d58063a control/controlclient: move lastPrintMap field from Direct to mapSession
It was a really a mutable field owned by mapSession that we didn't move
in earlier commits.

Once moved, it's then possible to de-func-ify the code and turn it into
a regular method rather than an installed optional hook.

Noticed while working to move map session lifetimes out of
Direct.sendMapRequest's single-HTTP-connection scope.

Updates #7175
Updates #cleanup

Change-Id: I6446b15793953d88d1cabf94b5943bb3ccac3ad9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-05 08:53:47 -08:00
Matt Layher
1a1e0f460a client/tailscale: remove redundant error check
Signed-off-by: Matt Layher <mdlayher@gmail.com>
2023-11-05 07:40:23 -08:00
Will Norris
e537d304ef client/web: relax CSP restrictions for manage client
Don't return CSP headers in dev mode, since that includes a bunch of
extra things like the vite server.

Allow images from any source, which is needed to load user profile
images.

Allow 'unsafe-inline' for various inline scripts and style react uses.
We can eliminate this by using CSP nonce or hash values, but we'll need
to look into the best way to handle that. There appear to be several
react plugins for this, but I haven't evaluated any of them.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-05 01:11:21 -07:00
Claire Wang
5de8650466 syspolicy: add Allow LAN Access visibility key (#10113)
Fixes tailscale/corp#15594

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-11-04 15:51:20 -04:00
Brad Fitzpatrick
b2b836214c derp/derphttp: fix derptrack fix
3d7fb6c21d dropped the explicit called to (*Client).connect when
its (*Client).WatchConnectionChanges got removed+refactored.

This puts it back, but in RunWatchConnectionLoop, before the call
to the (*Client).ServerPublicKey accessor, which is documented to
return the zero value (which is what broke us) on an unconnected
connection.

Plus some tests.

Fixes tailscale/corp#15604

Change-Id: I0f242816f5ee4ad3bb0bf0400abc961dbe9f5fc8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-04 11:48:40 -07:00
Flakes Updater
8dc6de6f58 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-03 19:28:46 -07:00
Will Norris
7e81c83e64 cmd/tailscale: respect existing web client pref
After running `tailscale web`, only disable the user pref if it was not
already previously set.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-03 17:26:52 -07:00
Will Norris
cb07ed54c6 go.mod: update web-client-prebuilt
Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-03 15:00:41 -07:00
Will Norris
a05ab9f3bc client/web: check r.Host rather than r.URL.Host
r.URL.Host is not typically populated on server requests.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-03 14:55:16 -07:00
Will Norris
6b956b49e0 client/web: add some security checks for full client
Require that requests to servers in manage mode are made to the
Tailscale IP (either ipv4 or ipv6) or quad-100. Also set various
security headers on those responses.  These might be too restrictive,
but we can relax them as needed.

Allow requests to /ok (even in manage mode) with no checks. This will be
used for the connectivity check from a login client to see if the
management client is reachable.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-03 14:15:59 -07:00
Aaron Klotz
fbc18410ad ipn/ipnauth: improve the Windows token administrator check
(*Token).IsAdministrator is supposed to return true even when the user is
running with a UAC limited token. The idea is that, for the purposes of
this check, we don't care whether the user is *currently* running with
full Admin rights, we just want to know whether the user can
*potentially* do so.

We accomplish this by querying for the token's "linked token," which
should be the fully-elevated variant, and checking its group memberships.

We also switch ipn/ipnserver/(*Server).connIsLocalAdmin to use the elevation
check to preserve those semantics for tailscale serve; I want the
IsAdministrator check to be used for less sensitive things like toggling
auto-update on and off.

Fixes #10036

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-11-03 14:37:04 -06:00
Sonia Appasamy
e5dcf7bdde client/web: move auth session creation out of /api/auth
Splits auth session creation into two new endpoints:

/api/auth/session/new - to request a new auth session

/api/auth/session/wait - to block until user has completed auth url

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-03 15:30:04 -04:00
Will Norris
658971d7c0 ipn/ipnlocal: serve web client on quad100 if enabled
if the user pref and nodecap for the new web client are enabled, serve
the client over requests to 100.100.100.100.  Today, that is just a
static page that lists the local Tailcale IP addresses.

For now, this will render the readonly full management client, with an
"access" button that sends the user through check mode.  After
completing check mode, they will still be in the read-only view, since
they are not accessing the client over Tailscale.

Instead, quad100 should serve the lobby client that has a "manage"
button that will open the management client on the Tailscale IP (and
trigger check mode). That is something we'll fix in a subsequent PR in
the web client code itself.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-03 12:29:10 -07:00
James Tucker
46fd488a6d types/dnstype: update the usage documentation on dnstype.Resolver
There was pre-existing additional usage for Exit Node DNS resolution via
PeerAPI, as well as new usage just introduced for App Connectors.

Fixes ENG-2324
Updates #cleanup
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-03 09:01:11 -07:00
Sonia Appasamy
0ecfc1d5c3 client/web: fill devMode from an env var
Avoids the need to pipe a web client dev flag through the tailscaled
command.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-02 21:51:33 -04:00
Andrew Lytvynov
f0bc95a066 ipn/localapi: make serveTKASign require write permission (#10094)
The existing read permission check looks like an oversight. Write seems
more appropriate for sining new nodes.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-02 17:01:26 -06:00
Sonia Appasamy
191e2ce719 client/web: add ServerMode to web.Server
Adds a new Mode to the web server, indicating the specific
scenario the constructed server is intended to be run in. Also
starts filling this from the cli/web and ipn/ipnlocal callers.

From cli/web this gets filled conditionally based on whether the
preview web client node cap is set. If not set, the existing
"legacy" client is served. If set, both a login/lobby and full
management client are started (in "login" and "manage" modes
respectively).

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-02 17:20:05 -04:00
Andrew Lytvynov
7145016414 clientupdate: do not recursively delete dirs in cleanupOldDownloads (#10093)
In case there's a wild symlink in one of the target paths, we don't want
to accidentally delete too much. Limit `cleanupOldDownloads` to deleting
individual files only.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-02 13:29:52 -07:00
Will Norris
4ce4bb6271 client/web: limit authorization checks to API calls
This completes the migration to setting up authentication state in the
client first before fetching any node data or rendering the client view.

Notable changes:
 - `authorizeRequest` is now only enforced on `/api/*` calls (with the
   exception of /api/auth, which is handled early because it's needed to
   initially setup auth, particularly for synology)
 - re-separate the App and WebClient components to ensure that auth is
   completed before moving on
 - refactor platform auth (synology and QNAP) to fit into this new
   structure. Synology no longer returns redirect for auth, but returns
   authResponse instructing the client to fetch a SynoToken

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-02 13:01:09 -07:00
James Tucker
f27b2cf569 appc,cmd/sniproxy,ipn/ipnlocal: split sniproxy configuration code out of appc
The design changed during integration and testing, resulting in the
earlier implementation growing in the appc package to be intended now
only for the sniproxy implementation. That code is moved to it's final
location, and the current App Connector code is now renamed.

Updates tailscale/corp#15437

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-02 12:51:40 -07:00
Andrew Lytvynov
6c0ac8bef3 clientupdate: cleanup SPK and MSI downloads (#10085)
After we're done installing, clean up the temp files. This prevents temp
volumes from filling up on hosts that don't reboot often.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-02 12:21:42 -06:00
Sonia Appasamy
aa5af06165 ipn/ipnlocal: include web client port in setTCPPortsIntercepted
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-02 13:56:26 -04:00
Sonia Appasamy
da31ce3a64 ipn/localapi: remove webclient endpoint
Managing starting/stopping tailscaled web client purely via setting
the RunWebClient pref.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-02 13:42:55 -04:00
Sonia Appasamy
b370274b29 ipn/ipnlocal: pull CapabilityPreviewWebClient into webClientAtomicBool
Now uses webClientAtomicBool as the source of truth for whether the web
client should be running in tailscaled, with it updated when either the
RunWebClient pref or CapabilityPreviewWebClient node capability changes.

This avoids requiring holding the LocalBackend lock on each call to
ShouldRunWebClient to check for the CapabilityPreviewWebClient value.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-02 13:22:50 -04:00
Andrew Lytvynov
c6a4612915 ipn/localapi: require Write access on /watch-ipn-bus with private keys (#10059)
Clients optionally request private key filtering. If they don't, we
should require Write access for the user.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-02 09:48:10 -07:00
Aaron Klotz
47019ce1f1 cmd/tailscaled: pre-load wintun.dll using a fully-qualified path
In corp PR #14970 I updated the installer to set a security mitigation that
always forces system32 to the front of the Windows dynamic linker's search
path.

Unfortunately there are other products out there that, partying like it's
1995, drop their own, older version of wintun.dll into system32. Since we
look there first, we end up loading that old version.

We can fix this by preloading wintun using a fully-qualified path. When
wintun-go then loads wintun, the dynamic linker will hand it the module
that was previously loaded by us.

Fixes #10023, #10025, #10052

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-11-02 09:47:21 -06:00
Irbe Krumina
af49bcaa52 cmd/k8s-operator: set different app type for operator with proxy (#10081)
Updates tailscale/tailscale#9222

plain k8s-operator should have hostinfo.App set to 'k8s-operator', operator with proxy should have it set to 'k8s-operator-proxy'. In proxy mode, we were setting the type after it had already been set to 'k8s-operator'

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-02 14:36:20 +00:00
Brad Fitzpatrick
673ff2cb0b util/groupmember: fail earlier if group doesn't exist, use slices.Contains
Noticed both while re-reading this code.

Updates #cleanup

Change-Id: I3b70f1d5dc372853fa292ae1adbdee8cfc6a9a7b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-01 19:23:16 -07:00
James Tucker
228a82f178 ipn/ipnlocal,tailcfg: add AppConnector service to HostInfo when configured
Updates tailscale/corp#15437

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-01 16:37:24 -07:00
James Tucker
6ad54fed00 appc,ipn/ipnlocal: add App Connector domain configuration from mapcap
The AppConnector is now configured by the mapcap from the control plane.

Updates tailscale/corp#15437

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-01 16:37:09 -07:00
James Tucker
e9de59a315 tstest/deptest: fix minor escaping error in regex
Fixes https://github.com/tailscale/tailscale/security/code-scanning/112

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-01 16:22:18 -07:00
James Tucker
b48b7d82d0 appc,ipn/ipnlocal,net/dns/resolver: add App Connector wiring when enabled in prefs
An EmbeddedAppConnector is added that when configured observes DNS
responses from the PeerAPI. If a response is found matching a configured
domain, routes are advertised when necessary.

The wiring from a configuration in the netmap capmap is not yet done, so
while the connector can be enabled, no domains can yet be added.

Updates tailscale/corp#15437

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-01 16:09:08 -07:00
Will Norris
e7482f0df0 ipn/ipnlocal: prevent deadlock on WebClientShutdown
WebClientShutdown tries to acquire the b.mu lock, so run it in a go
routine so that it can finish shutdown after setPrefsLockedOnEntry is
finished. This is the same reason b.sshServer.Shutdown is run in a go
routine.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-01 15:36:05 -07:00
Sonia Appasamy
7a725bb4f0 client/web: move more session logic to auth.go
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-01 18:35:43 -04:00
dependabot[bot]
535cb6c3f5 build(deps): bump github.com/docker/docker
Bumps [github.com/docker/docker](https://github.com/docker/docker) from 24.0.6+incompatible to 24.0.7+incompatible.
- [Release notes](https://github.com/docker/docker/releases)
- [Commits](https://github.com/docker/docker/compare/v24.0.6...v24.0.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 15:28:49 -07:00
dependabot[bot]
f2bc54ba15 build(deps-dev): bump postcss from 8.4.27 to 8.4.31 in /client/web
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.27 to 8.4.31.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.27...8.4.31)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 15:25:57 -07:00
dependabot[bot]
6cc81a6d3e build(deps): bump get-func-name from 2.0.0 to 2.0.2 in /client/web
Bumps [get-func-name](https://github.com/chaijs/get-func-name) from 2.0.0 to 2.0.2.
- [Release notes](https://github.com/chaijs/get-func-name/releases)
- [Commits](https://github.com/chaijs/get-func-name/commits/v2.0.2)

---
updated-dependencies:
- dependency-name: get-func-name
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 15:25:29 -07:00
dependabot[bot]
80fc32588c build(deps): bump @babel/traverse from 7.22.10 to 7.23.2 in /client/web
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.10 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 15:24:09 -07:00
Will Norris
e5fbe57908 web/client: update synology token from /api/auth call
When the /api/auth response indicates that synology auth is needed,
fetch the SynoToken and store it for future API calls.  This doesn't yet
update the server-side code to set the new SynoAuth field.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-01 14:18:40 -07:00
dependabot[bot]
b1a0caf056 .github: Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 13:26:33 -07:00
Andrew Lytvynov
7f16e000c9 clientupdate: clarify how to run update as Administrator on Windows (#10043)
Make the error message a bit more helpful for users.

Updates #9456

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-01 13:15:17 -07:00
James Tucker
01604c06d2 hostinfo: fix a couple of logic simplification lints
Updates #cleanup
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-01 13:14:25 -07:00
David Anderson
37863205ec cmd/k8s-operator: strip credentials from client config in noauth mode
Updates tailscale/corp#15526

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-11-01 13:13:40 -07:00
James Tucker
0ee4573a41 ipn/ipnlocal: fix small typo
Updates #cleanup
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-01 12:42:11 -07:00
Will Norris
237c6c44cd client/web: call /api/auth before rendering any client views
For now this is effectively a noop, since only the ManagementClientView
uses the auth data. That will change soon.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-01 12:09:38 -07:00
Rhea Ghosh
970eb5e784 cmd/k8s-operator: sanitize connection headers (#10063)
Fixes tailscale/corp#15526

Signed-off-by: Rhea Ghosh <rhea@tailscale.com>
2023-11-01 13:15:57 -05:00
James Tucker
ca4c940a4d ipn: introduce app connector advertisement preference and flags
Introduce a preference structure to store the setting for app connector
advertisement.

Introduce the associated flags:

  tailscale up --advertise-connector{=true,=false}
  tailscale set --advertise-connector{=true,=false}

```
% tailscale set --advertise-connector=false
% tailscale debug prefs | jq .AppConnector.Advertise
false
% tailscale set --advertise-connector=true
% tailscale debug prefs | jq .AppConnector.Advertise
true
% tailscale up --advertise-connector=false
% tailscale debug prefs | jq .AppConnector.Advertise
false
% tailscale up --advertise-connector=true
% tailscale debug prefs | jq .AppConnector.Advertise
true
```

Updates tailscale/corp#15437

Signed-off-by: James Tucker <james@tailscale.com>
2023-11-01 10:58:54 -07:00
James Tucker
09fcbae900 net/dnscache: remove completed TODO
The other IP types don't appear to be imported anymore, and after a scan
through I couldn't see any substantial usage of other representations,
so I think this TODO is complete.

Updates #cleanup
Signed-off-by: James Tucker <james@tailscale.com>
2023-11-01 10:55:47 -07:00
Sonia Appasamy
32ebc03591 client/web: move session logic to auth.go
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-01 13:29:59 -04:00
Chris Palmer
3a9f5c02bf util/set: make Clone a method (#10044)
Updates #cleanup

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
2023-11-01 10:20:38 -07:00
Derek Kaser
5289cfce33 clientupdate: disable on Unraid (#10048)
* clientupdate: disable on Unraid

Updates dkaser/unraid-tailscale#94

Signed-off-by: Derek Kaser <derek.kaser@gmail.com>

* Update clientupdate/clientupdate.go

Signed-off-by: Andrew Lytvynov <andrew@awly.dev>

---------

Signed-off-by: Derek Kaser <derek.kaser@gmail.com>
Signed-off-by: Andrew Lytvynov <andrew@awly.dev>
Co-authored-by: Andrew Lytvynov <andrew@awly.dev>
2023-11-01 10:19:22 -07:00
Irbe Krumina
c2b87fcb46 cmd/k8s-operator/deploy/chart,.github/workflows: use helm chart API v2 (#10055)
API v1 is compatible with helm v2 and v2 is not.
However, helm v2 (the Tiller deployment mechanism) was deprecated in 2020
and no-one should be using it anymore.
This PR also adds a CI lint test for helm chart

Updates tailscale/tailscale#9222

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-01 16:15:18 +00:00
Maisem Ali
d0f2c0664b wgengine/netstack: standardize var names in UpdateNetstackIPs
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-11-01 08:29:32 -07:00
Maisem Ali
eaf8aa63fc wgengine/netstack: remove unnecessary map in UpdateNetstackIPs
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-11-01 08:29:32 -07:00
Maisem Ali
d601c81c51 wgengine/netstack: use netip.Prefix as map keys
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-11-01 08:29:32 -07:00
Anton Tolchanov
c3313133b9 derp/derphttp: close DERP client to avoid data race in test
Fixes #10041

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-11-01 07:07:42 -07:00
Will Norris
66c7af3dd3 ipn: replace web client debug flag with node capability
Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-10-31 20:51:24 -07:00
Jordan Whited
bd488e4ff8 go.mod: update wireguard-go (#10046)
Updates tailscale/corp#9990

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-10-31 19:56:03 -07:00
Chris Palmer
00375f56ea util/set: add some more Set operations (#10022)
Updates #cleanup

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
2023-10-31 17:15:40 -07:00
Andrew Lytvynov
7f3208592f clientupdate: mention release track when running latest (#10039)
Not all users know about our tracks and versioning scheme. They can be
confused when e.g. 1.52.0 is out but 1.53.0 is available. Or when 1.52.0
is our but 1.53 has not been built yet and user is on 1.51.x.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-31 15:50:55 -07:00
Sonia Appasamy
44175653dc ipn/ipnlocal: rename web fields/structs to webClient
For consistency and clarity around what the LocalBackend.web field
is used for.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-31 15:40:06 -04:00
Anton Tolchanov
3114a1c88d derp/derphttp: add watch reconnection tests from #9719
Co-authored-by: Val <valerie@tailscale.com>
Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-10-31 19:24:25 +00:00
Brad Fitzpatrick
3d7fb6c21d derp/derphttp: fix race in mesh watcher
The derphttp client automatically reconnects upon failure.

RunWatchConnectionLoop called derphttp.Client.WatchConnectionChanges
once, but that wrapper method called the underlying
derp.Client.WatchConnectionChanges exactly once on derphttp.Client's
currently active connection. If there's a failure, we need to re-subscribe
upon all reconnections.

This removes the derphttp.Client.WatchConnectionChanges method, which
was basically impossible to use correctly, and changes it to be a
boolean field on derphttp.Client alongside MeshKey and IsProber. Then
it moves the call to the underlying derp.Client.WatchConnectionChanges
to derphttp's client connection code, so it's resubscribed on any
reconnect.

Some paranoia is then added to make sure people hold the API right,
not calling derphttp.Client.RunWatchConnectionLoop on an
already-started Client without having set the bool to true. (But still
auto-setting it to true if that's the first method that's been called
on that derphttp.Client, as is commonly the case, and prevents
existing code from breaking)

Fixes tailscale/corp#9916
Supercedes tailscale/tailscale#9719

Co-authored-by: Val <valerie@tailscale.com>
Co-authored-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Anton Tolchanov <anton@tailscale.com>
Signed-off-by: Brad Fitzpatrick <brad@danga.com>
2023-10-31 19:24:25 +00:00
Tom DNetto
df4b730438 types/appctype: define the nodeAttrs type for dns-driven app connectors
Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates: https://github.com/tailscale/corp/issues/15440
Code-authored-by: Podtato <podtato@tailscale.com>
2023-10-31 12:34:09 -06:00
Tom DNetto
a7c80c332a cmd/sniproxy: implement support for control configuration, multiple addresses
* Implement missing tests for sniproxy
 * Wire sniproxy to new appc package
 * Add support to tsnet for routing subnet router traffic into netstack, so it can be handled

Updates: https://github.com/tailscale/corp/issues/15038
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-10-31 12:19:17 -06:00
Flakes Updater
0d86eb9da5 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-10-31 10:36:16 -07:00
Will Norris
ea599b018c ipn: serve web client requests from LocalBackend
instead of starting a separate server listening on a particular port,
use the TCPHandlerForDst method to intercept requests for the special
web client port (currently 5252, probably configurable later).

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-10-31 10:34:56 -07:00
Will Norris
28ad910840 ipn: add user pref for running web client
This is not currently exposed as a user-settable preference through
`tailscale up` or `tailscale set`.  Instead, the preference is set when
turning the web client on and off via localapi. In a subsequent commit,
the pref will be used to automatically start the web client on startup
when appropriate.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-10-31 10:34:56 -07:00
Jordan Whited
dd842d4d37 go.mod: update wireguard-go to enable TUN UDP GSO/GRO (#10029)
Updates tailscale/corp#9990

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-10-31 10:23:52 -07:00
Sonia Appasamy
6f214dec48 client/web: split out UI components
This commit makes the following structural changes to the web
client interface. No user-visible changes.

1. Splits login, legacy, readonly, and full management clients into
   their own components, and pulls them out into their own view files.
2. Renders the same Login component for all scenarios when the client
   is not logged in, regardless of legacy or debug mode. Styling comes
   from the existing legacy login, which is removed from legacy.tsx
   now that it is shared.
3. Adds a ui folder to hold non-Tailscale-specific components,
   starting with ProfilePic, previously housed in app.tsx.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-31 13:15:07 -04:00
Sonia Appasamy
89953b015b ipn/ipnlocal,client/web: add web client to tailscaled
Allows for serving the web interface from tailscaled, with the
ability to start and stop the server via localapi endpoints
(/web/start and /web/stop).

This will be used to run the new full management web client,
which will only be accessible over Tailscale (with an extra auth
check step over noise) from the daemon. This switch also allows
us to run the web interface as a long-lived service in environments
where the CLI version is restricted to CGI, allowing us to manage
certain auth state in memory.

ipn/ipnlocal/web is stubbed out in ipn/ipnlocal/web_stub for
ios builds to satisfy ios restriction from adding "text/template"
and "html/template" dependencies.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-31 13:15:07 -04:00
Sonia Appasamy
93aa8a8cff client/web: allow providing logger implementation
Also report metrics in separate go routine with a 5 second timeout.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-31 13:15:07 -04:00
Andrea Gottardo
95715c4a12 ipn/localapi: add endpoint to handle APNS payloads (#9972)
* ipn/localapi: add endpoint to handle APNS payloads

Fixes #9971. This adds a new `handle-push-message` local API endpoint. When an APNS payload is delivered to the main app, this endpoint can be used to forward the JSON body of the message to the backend, making a POST request.

cc @bradfitz

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

* Address comments from code review

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

---------

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
2023-10-30 18:35:53 -07:00
Andrew Dunham
57c5b5a77e net/dns/recursive: update IP for b.root-servers.net
As of 2023-11-27, the official IP addresses for b.root-servers.net will
change to a new set, with the older IP addresses supported for at least
a year after that date. These IPs are already active and returning
results, so update these in our recursive DNS resolver package so as to
be ready for the switchover.

See: https://b.root-servers.org/news/2023/05/16/new-addresses.html

Fixes #9994

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I29e2fe9f019163c9ec0e62bdb286e124aa90a487
2023-10-30 15:39:55 -04:00
Tom DNetto
3df305b764 tsnet: enable use-cases with non-native IPs by setting ns.ProcessSubnets
Terminating traffic to IPs which are not the native IPs of the node requires
the netstack subsystem to intercept trafic to an IP it does not consider local.
This PR switches on such interception. In addition to supporting such termination,
this change will also enable exit nodes and subnet routers when running in
userspace mode.

DO NOT MERGE until 1.52 is cut.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates: https://github.com/tailscale/corp/issues/15038
2023-10-30 12:25:27 -06:00
Irbe Krumina
452f900589 tool: download helm CLI (#9981)
Updates tailscale/tailscale#9222

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-10-30 18:20:33 +00:00
Irbe Krumina
ed1b935238 cmd/k8s-operator: allow to install operator via helm (#9920)
Initial helm manifests.

Updates tailscale/tailscale#9222

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Maisem Ali <maisem@tailscale.com>
2023-10-30 18:18:09 +00:00
Tyler Smalley
fde2ba5bb3 VERSION.txt: this is v1.53.0 (#10018)
Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-30 10:45:14 -07:00
Maisem Ali
62d580f0e8 util/linuxfw: add missing error checks in tests
This would surface as panics when run on Fly. Still fail, but at least don't panic.

Updates #10003

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-28 09:44:53 -07:00
Rhea Ghosh
387a98fe28 ipn/ipnlocal: exclude tvOS devices from taildrop file targets (#10002) 2023-10-27 16:35:18 -05:00
Chris Palmer
f66dc8dc0a clientupdate: check for privileges earlier (#9964)
Fixes #9963

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
2023-10-27 10:43:50 -07:00
Flakes Updater
f9fafe269a go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-10-27 10:37:26 -07:00
Cole Helbling
087260734b flake: drop unnecessary git input
Signed-off-by: Cole Helbling <cole.helbling@determinate.systems>
2023-10-27 10:33:50 -07:00
Cole Helbling
561e7b61c3 flake: fix systemd service by hardcoding $PORT
Another solution would be to copy the `.defaults` file alongside the
service file, and set the `EnvironmentFile` to point to that, but it
would still be hardcoded (as the `.defaults` file would be stored in the
Nix store), so I figured that this is a good solution until there is a
proper NixOS module.

Fixes #9995.

Signed-off-by: Cole Helbling <cole.helbling@determinate.systems>
2023-10-27 10:33:50 -07:00
Cole Helbling
9e71851a36 go.mod.sri: update
Signed-off-by: Cole Helbling <cole.helbling@determinate.systems>
2023-10-27 10:33:50 -07:00
Cole Helbling
4f62a2ed99 flake: set a default package
This allows `nix build` to run, without needing to use `nix build
.#tailscale`.

Signed-off-by: Cole Helbling <cole.helbling@determinate.systems>
2023-10-27 10:33:50 -07:00
Cole Helbling
f737496d7c flake: drop unnecessary fileContents binding
Since the tailscale derivation already has a `pkgs` binding, we can
use `pkgs.lib`. Alternatively, we could have used `nixpkgs.lib`, as
`fileContents` doesn't need a system to use (anymore?).

Signed-off-by: Cole Helbling <cole.helbling@determinate.systems>
2023-10-27 10:33:50 -07:00
Maisem Ali
9107b5eadf cmd/tailscale/cli: use status before doing interactive feature query
We were inconsistent whether we checked if the feature was already
enabled which we could do cheaply using the locally available status.
We would do the checks fine if we were turning on funnel, but not serve.

This moves the cap checks down into enableFeatureInteractive so that
are always run.

Updates #9984

Co-authored-by: Tyler Smalley <tyler@tailscale.com>
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-26 17:10:24 -07:00
License Updater
e94d345e26 licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-10-26 15:21:33 -07:00
License Updater
7c7f60be22 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-10-26 15:20:25 -07:00
Tyler Smalley
baa1fd976e ipn/localapi: require local Windows admin to set serve path (#9969)
For a serve config with a path handler, ensure the caller is a local administrator on Windows.

updates #8489

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-26 14:40:44 -07:00
Will Norris
42abf13843 .github: run tests on all PRs, regardless of branch name
The branch name selector "*" doesn't match branches with a "/" in their
name. The vast majority of our PRs are against the main (or previously,
master) branch anyway, so this will have minimal impact. But in the rare
cases that we want to open a PR against a branch with a "/" in the name,
tests should still run.

```
gh pr list --limit 9999 --state all --json baseRefName | \
  jq -cs '.[] | group_by(.baseRefName) |
    map({ base: .[0].baseRefName, count: map(.baseRefName) | length}) |
    sort_by(-.count) | .[]'

{"base":"main","count":4593}
{"base":"master","count":226}
{"base":"release-branch/1.48","count":4}
{"base":"josh-and-adrian-io_uring","count":3}
{"base":"release-branch/1.30","count":3}
{"base":"release-branch/1.32","count":3}
{"base":"release-branch/1.20","count":2}
{"base":"release-branch/1.26","count":2}
{"base":"release-branch/1.34","count":2}
{"base":"release-branch/1.38","count":2}
{"base":"Aadi/speedtest-tailscaled","count":1}
{"base":"josh/io_uring","count":1}
{"base":"maisem/hi","count":1}
{"base":"rel-144","count":1}
{"base":"release-branch/1.18","count":1}
{"base":"release-branch/1.2","count":1}
{"base":"release-branch/1.22","count":1}
{"base":"release-branch/1.24","count":1}
{"base":"release-branch/1.4","count":1}
{"base":"release-branch/1.46","count":1}
{"base":"release-branch/1.8","count":1}
{"base":"web-client-main","count":1}
```

Updates #cleanup

Signed-off-by: Will Norris <will@tailscale.com>
2023-10-26 10:47:39 -07:00
Brad Fitzpatrick
b4be4f089f safesocket: make clear which net.Conns are winio types
Follow-up to earlier #9049.

Updates #9049

Change-Id: I121fbd2468770233a23ab5ee3df42698ca1dabc2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-26 10:11:08 -07:00
Aaron Klotz
95671b71a6 ipn, safesocket: use Windows token in LocalAPI
On Windows, the idiomatic way to check access on a named pipe is for
the server to impersonate the client on its current OS thread, perform
access checks using the client's access token, and then revert the OS
thread's access token back to its true self.

The access token is a better representation of the client's rights than just
a username/userid check, as it represents the client's effective rights
at connection time, which might differ from their normal rights.

This patch updates safesocket to do the aforementioned impersonation,
extract the token handle, and then revert the impersonation. We retain
the token handle for the remaining duration of the connection (the token
continues to be valid even after we have reverted back to self).

Since the token is a property of the connection, I changed ipnauth to wrap
the concrete net.Conn to include the token. I then plumbed that change
through ipnlocal, ipnserver, and localapi as necessary.

I also added a PermitLocalAdmin flag to the localapi Handler which I intend
to use for controlling access to a few new localapi endpoints intended
for configuring auto-update.

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

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-10-26 09:43:19 -06:00
Andrew Dunham
ef596aed9b net/portmapper: avoid alloc in getUPnPErrorsMetric
Updates #cleanup

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Iea558024c038face24cc46584421998d10f13a66
2023-10-26 03:02:27 +02:00
James Tucker
237b4b5a2a .github/workflows: add checklocks
Currently the checklocks step is not configured to fail, as we do not
yet have the appropriate annotations.

Updates tailscale/corp#14381

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-25 16:39:31 -07:00
Tyler Smalley
131518eed1 cmd/tailscale/cli: improve error when bg serve config is present (#9961)
We prevent shodow configs when starting a foreground when a background serve config already exists for the serve type and port. This PR improves the messaging to let the user know how to remove the previous config.

Updates #8489
ENG-2314

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-25 14:27:46 -07:00
Tyler Smalley
1873bc471b cmd/tailscale/cli: remove http flag for funnel command (#9955)
The `--http` flag can not be used with Funnel, so we should remove it to remove confusion.

Updates #8489
ENG-2316

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-25 14:05:54 -07:00
Val
19e5f242e0 net/portmapper: convert UPnP metrics to new syncs.Map.LoadOrInit method
Simplify UPnP error metrics by using the new syncs.Map.LoadOrInit method.

Updates #cleanup

Signed-off-by: Val <valerie@tailscale.com>
2023-10-25 22:39:47 +02:00
Andrew Lytvynov
8326fdd60f clientupdate: disable auto-updates on Synology for now (#9965)
Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-25 16:02:46 -04:00
Flakes Updater
143bda87a3 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-10-24 20:16:27 -07:00
Marwan Sulaiman
5f3cdaf283 cmd/tailscale/cli: chage port flags to uint for serve and funnel
This PR changes the -https, -http, -tcp, and -tls-terminated-tcp
flags from string to int and also updates the validation to ensure
they fit the uint16 size as the flag library does not have a Uint16Var
method.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-10-24 20:12:17 -04:00
Andrea Gottardo
741d7bcefe Revert "ipn/ipnlocal: add new DNS and subnet router policies" (#9962)
This reverts commit 32194cdc70.

Signed-off-by: Nick O'Neill <nick@tailscale.com>
2023-10-24 17:07:25 -07:00
Marwan Sulaiman
a7e4cebb90 cmd/tailscale/cli: refactor TestServeDevConfigMutations
The TestServeDevConfigMutations test has 63 steps that all run
under the same scope. This tests breaks them out into isolated
subtests that can be run independently.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-10-24 18:07:24 -04:00
Sonia Appasamy
d79e0fde9c client/web: split errTaggedSelf resp from getTailscaleBrowserSession
Previously returned errTaggedSource in the case that of any tagged
source. Now distinguishing whether the source was local or remote.
We'll be presenting the two cases with varying copy on the frontend.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-24 16:12:07 -04:00
Sonia Appasamy
e0a4a02b35 client/web: pipe Server.timeNow() through session funcs
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-24 15:46:13 -04:00
Andrew Lytvynov
21b6d373b0 cmd/tailscale/cli: unhide auto-update flags and mark update as Beta (#9954)
Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-24 11:34:24 -07:00
Adrian Dewhurst
32194cdc70 ipn/ipnlocal: add new DNS and subnet router policies
In addition to the new policy keys for the new options, some
already-in-use but missing policy keys are also being added to
util/syspolicy.

Updates ENG-2133

Change-Id: Iad08ca47f839ea6a65f81b76b4f9ef21183ebdc6
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2023-10-24 14:33:59 -04:00
Marwan Sulaiman
f5a7551382 cmd/tailscale: fix help message for serve funnel
We currently print out "run tailscale serve --help" when the subcmd
might be funnel. This PR ensures the right subcmd is passed.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-10-24 14:32:11 -04:00
Andrew Lytvynov
d3bc575f35 cmd/tailscale/cli: set Sparkle auto-update on macsys (#9952)
On `tailscale set --auto-update`, set the Sparkle plist option for it.
Also make macsys report not supporting auto-updates over c2n, since they
will be triggered by Sparkle locally.

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-24 12:17:55 -06:00
James Tucker
6f69fe8ad7 wgnengine: remove unused field in userspaceEngine
Updates #cleanup

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-24 11:01:19 -07:00
Tyler Smalley
269a498c1e cmd/tailscale/cli: serve --set-path should not force background mode (#9905)
A few people have run into issues with understanding why `--set-path` started in background mode, and/or why they couldn't use a path in foreground mode. This change allows `--set-path` to be used in either case (foreground or background).

updates #8489

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-24 09:56:41 -07:00
Thomas Kosiewski
b2ae8fdf80 derp/derphttp: strip port numbers from URL hostname
When trying to set up multiple derper instances meshing with each
other, it turned out that while one can specify an alternative
listening port using the -a flag, the TLS hostname gets incorrectly
determined and includes the set alternative listening port as part of
the hostname. Thus, the TLS hostname validation always fails when the
-mesh-with values have ports.

Updates #9949

Signed-off-by: Thomas Kosiewski <thomas.kosiewski@loft.sh>
2023-10-24 07:27:29 -07:00
Brad Fitzpatrick
514539b611 wgengine/magicsock: close disco listeners on Conn.Close, fix Linux root TestNewConn
TestNewConn now passes as root on Linux. It wasn't closing the BPF
listeners and their goroutines.

The code is still a mess of two Close overlapping code paths, but that
can be refactored later. For now, make the two close paths more similar.

Updates #9945

Change-Id: I8a3cf5fb04d22ba29094243b8e645de293d9ed85
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-23 19:19:09 -07:00
Andrew Lytvynov
593c086866 clientupdate: distinguish when auto-updates are possible (#9896)
clientupdate.Updater will have a non-nil Update func in a few cases
where it doesn't actually perform an update:
* on Arch-like distros, where it prints instructions on how to update
* on macOS app store version, where it opens the app store page

Add a new clientupdate.Arguments field to cause NewUpdater to fail when
we hit one of these cases. This results in c2n updates being "not
supported" and `tailscale set --auto-update` returning an error.

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-23 18:21:54 -07:00
James Tucker
7df6f8736a wgengine/netstack: only add addresses to correct protocols
Prior to an earlier netstack bump this code used a string conversion
path to cover multiple cases of behavior seemingly checking for
unspecified addresses, adding unspecified addresses to v6. The behavior
is now crashy in netstack, as it is enforcing address length in various
areas of the API, one in particular being address removal.

As netstack is now protocol specific, we must not create invalid
protocol addresses - an address is v4 or v6, and the address value
contained inside must match. If a control path attempts to do something
otherwise it is now logged and skipped rather than incorrect addressing
being added.

Fixes tailscale/corp#15377

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-23 17:29:36 -07:00
Tyler Smalley
35d7b3aa27 cmd/tailscale/cli: updates help and background messaging (#9929)
* Fixes issue with template string not being provided in help text
* Updates background information to provide full URL, including path, to make it clear the source and destination
* Restores some tests
* Removes AllowFunnel in ServeConfig if no proxy exists for that port.

updates #8489

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-23 13:24:21 -07:00
Marwan Sulaiman
c53ee37912 cmd/tailscale: add set-raw to the new serve funnel commands
This PR adds the same set-raw from the old flow into the new one
so that users can continue to use it when transitioning into the new
flow.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-10-23 15:55:13 -04:00
Marwan Sulaiman
f232d4554a cmd/tailscale: translate old serve commands to new ones
This PR fixes the isLegacyInvocation to better catch serve and
funnel legacy commands. In addition, it now also returns a string
that translates the old command into the new one so that users
can have an easier transition story.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-10-23 15:54:24 -04:00
Sonia Appasamy
62d08d26b6 client/web: set Server.cgiMode field
Updates tailscale/corp#15373

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
Co-authored-by: Will Norris <will@tailscale.com>
2023-10-23 15:27:22 -04:00
Maisem Ali
17b2072b72 ipn/ipnlocal: set the push device token correctly
It would end up resetting whatever hostinfo we had constructed
and leave the backend statemachine in a broken state.
This fixes that by storing the PushDeviceToken on the LocalBackend
and populating it on Hostinfo before passing it to controlclient.

Updates tailscale/corp#8940
Updates tailscale/corp#15367

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-23 11:43:25 -07:00
Maisem Ali
0e89245c0f ipn/ipnlocal: drop hostinfo param from doSetHostinfoFilterServices
The value being passed was the same as whats on b.hostinfo, so just
use that directly.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-23 11:43:25 -07:00
Joe Tsai
152390e80a ipn/localapi: avoid unkeyed literal (#9933)
Go has no way to explicitly identify Go struct as effectively a tuple,
so staticcheck assumes any external use of unkeyed literals is wrong.

Updates #cleanup

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-23 11:15:25 -07:00
Marwan Sulaiman
60e768fd14 cmd/tailscale: allow serve|funnel off to delete an entire port
This PR allows you to do "tailscale serve -bg -https:4545 off" and it
will delete all handlers under it. It will also prompt you for a y/n in case
you wanted to delete a single port.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-10-23 13:42:10 -04:00
Andrew Lytvynov
e561f1ce61 clientupdate: manually restart Windows GUI after update (#9906)
When updating via c2n, `tailscale.exe update` runs from `tailscaled.exe`
which runs as SYSTEM. The MSI installer does not start the GUI when
running as SYSTEM. This results in Tailscale just existing on
auto-update, which is ungood.

Instead, always ask the MSI installer to not launch the GUI (via
`TS_NOLAUNCH` argument) and launch it manually with a token from the
current logged in user. The token code was borrowed from
d9081d6ba2/net/dns/wsl_windows.go (L207-L232)

Also, make some logging changes so that these issues are easier to debug
in the future.

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-23 10:24:57 -07:00
Irbe Krumina
e9956419f6 cmd/k8s-operator: allow cleanup of cluster resources for deleted devices (#9917)
Users should delete proxies by deleting or modifying the k8s cluster resources
that they used to tell the operator to create they proxy. With this flow,
the tailscale operator will delete the associated device from the control.
However, in some cases users might have already deleted the device from the control manually.

Updates tailscale/tailscale#9773

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-10-23 16:22:55 +01:00
Brad Fitzpatrick
e87862bce3 go.toolchain.rev: bump Tailscale Go toolchain
Updates tailscale/go#77

Change-Id: I367465fb90cd4369cfbafd913c3964bfe5553dd0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-22 16:20:41 -07:00
Maisem Ali
f398712c00 ipn/ipnlocal: prevent changing serve config if conf.Locked
This adds a check to prevent changes to ServeConfig if tailscaled
is run with a Locked config.

Missed in 1fc3573446.

Updates #1412

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-20 21:21:34 -07:00
Tyler Smalley
d9081d6ba2 cmd/tailscale/cli: update serve/funnel CLI help text (#9895)
updates #8489
ENG-2308

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-20 14:17:28 -07:00
Adrian Dewhurst
5347e6a292 control/controlclient: support certstore without cgo
We no longer build Windows releases with cgo enabled, which
automatically turned off certstore support. Rather than re-enabling cgo,
we updated our fork of the certstore package to no longer require cgo.
This updates the package, cleans up how the feature is configured, and
removes the cgo build tag requirement.

Fixes tailscale/corp#14797
Fixes tailscale/coral#118

Change-Id: Iaea34340761c0431d759370532c16a48c0913374
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2023-10-20 15:17:32 -04:00
Sonia Appasamy
68da15516f ipn/localapi,client/web: clean up auth error handling
This commit makes two changes to the web client auth flow error
handling:

1. Properly passes back the error code from the noise request from
   the localapi. Previously we were using io.Copy, which was always
   setting a 200 response status code.
2. Clean up web client browser sessions on any /wait endpoint error.
   This avoids the user getting in a stuck state if something goes
   wrong with their auth path.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-20 14:55:22 -04:00
Andrew Lytvynov
70f9c8a6ed clientupdate: change Mac App Store support (#9891)
In the sandboxed app from the app store, we cannot check
`/Library/Preferences/com.apple.commerce.plist` or run `softwareupdate`.
We can at most print a helpful message and open the app store page.

Also, reenable macsys update function to mark it as supporting c2n
updates. macsys support in `tailscale update` was fixed.

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-20 08:58:41 -07:00
Irbe Krumina
eced054796 ipn/ipnlocal: close connections for removed proxy transports (#9884)
Ensure that when a userspace proxy config is reloaded,
connections for any removed proxies are safely closed

Updates tailscale/tailscale#9725

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-10-20 12:04:00 +01:00
Sonia Appasamy
1df2d14c8f client/web: use auth ID in browser sessions
Stores ID from tailcfg.WebClientAuthResponse in browser session
data, and uses ID to hit control server /wait endpoint.

No longer need the control url cached, so removed that from Server.
Also added optional timeNow field, initially to manage time from
tests.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-19 16:32:43 -04:00
Joe Tsai
6ada33db77 taildrop: fix theoretical race condition in fileDeleter.Init (#9876)
It is possible that upon a cold-start, we enqueue a partial file
for deletion that is resumed shortly after startup.

If the file transfer happens to last longer than deleteDelay,
we will delete the partial file, which is unfortunate.
The client spent a long time uploading a file,
only for it to be accidentally deleted.
It's a very rare race, but also a frustrating one
if it happens to manifest.

Fix the code to only delete partial files that
do not have an active puts against it.

We also fix a minor bug in ResumeReader
where we read b[:blockSize] instead of b[:cs.Size].
The former is the fixed size of 64KiB,
while the latter is usually 64KiB,
but may be less for the last block.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-19 13:26:55 -07:00
Andrew Lytvynov
25b6974219 ipn/ipnlocal: send ClientVersion to Apple frontends (#9887)
Apple frontends will now understand this Notify field and handle it.

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-19 12:50:21 -07:00
Sonia Appasamy
b4247fabec tailcfg: add ID field to WebClientAuthResponse
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-19 15:13:57 -04:00
Tom DNetto
7e933a8816 appctype: move to types/appctype
Having a types package at the top level was almost certainly unintentional.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates: https://github.com/tailscale/corp/issues/15038
2023-10-19 12:00:54 -07:00
Tom DNetto
02908a2d8d appc: implement app connector Server type
This change refactors & moves the bulk of the app connector logic from
./cmd/sniproxy.

A future change will delete the delta in sniproxy and wire it to this type.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates: https://github.com/tailscale/corp/issues/15038
2023-10-19 11:35:26 -07:00
Joe Tsai
469b7cabad cmd/tailscale: improve taildrop progress printer on Linux (#9878)
The progress printer was buggy where it would not print correctly
and some of the truncation logic was faulty.

The progress printer now prints something like:

	go1.21.3.linux-amd64.tar.gz               21.53MiB      13.83MiB/s     33.88%    ETA 00:00:03

where it shows
* the number of bytes transferred so far
* the rate of bytes transferred
  (using a 1-second half-life for an exponentially weighted average)
* the progress made as a percentage
* the estimated time
  (as calculated from the rate of bytes transferred)

Other changes:
* It now correctly prints the progress for very small files
* It prints at a faster rate (4Hz instead of 1Hz)
* It uses IEC units for byte quantities
  (to avoid ambiguities of "kb" being kilobits or kilobytes)

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-19 11:04:33 -07:00
Tyler Smalley
7a3ae39025 cmd/tailscale/cli: [serve/funnel] support omitting scheme for TCP (#9856)
The `serve` command for TCP  has always required the scheme of the target to be specified. However, when it's omitted the error message reported is misleading

```
error: failed to apply TCP serve: invalid TCP target "localhost:5900": missing port in address
```

Since we know the target is TCP, we shouldn't require it to be specified. This aligns with the changes for HTTP proxies in https://github.com/tailscale/tailscale/issues/8489

closes #9855

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-19 11:03:06 -07:00
Tyler Smalley
35376d52d4 cmd/tailscale/cli: [serve/funnel] provide correct command for disabling (#9859)
The `off` subcommand removes a serve/funnel for the corresponding type and port. Previously, we were not providing this which would result in an error if someone was using something than the default https=443.

closes #9858

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-19 10:46:44 -07:00
Irbe Krumina
f09cb45f9d ipn/ipnlocal: initiate proxy transport once (#9883)
Initiates http/h2c transport for userspace proxy
backend lazily and at most once.

Updates tailscale/tailscale#9725

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-10-19 18:38:37 +01:00
Sonia Appasamy
73bbf941f8 client/web: hook up auth flow
Connects serveTailscaleAuth to the localapi webclient endpoint
and pipes auth URLs and session cookies back to the browser to
redirect users from the frontend.

All behind debug flags for now.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-19 13:26:22 -04:00
Irbe Krumina
09b5bb3e55 ipn/ipnlocal: proxy gRPC requests over h2c if needed. (#9847)
Updates userspace proxy to detect plaintext grpc requests
using the preconfigured host prefix and request's content
type header and ensure that these will be proxied over h2c.

Updates tailscale/tailscale#9725

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-10-19 07:12:31 +01:00
Jordan Whited
891d964bd4 wgengine/magicsock: simplify tryEnableUDPOffload() (#9872)
Don't assume Linux lacks UDP_GRO support if it lacks UDP_SEGMENT
support. This mirrors a similar change in wireguard/wireguard-go@177caa7
for consistency sake. We haven't found any issues here, just being
overly paranoid.

Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-10-18 18:50:40 -07:00
Joe Tsai
d603d18956 taildrop: fix TestResume (#9874)
Previously, the test simply relied on:
	defer close()
to cleanup file handles.

This works fine on Unix-based systems,
but not on Windows, which dislikes deleting files
where an open file handle continues to exist.

Fix the test by explicitly closing the file handle
after we are done with the resource.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-18 18:07:30 -07:00
Brad Fitzpatrick
cf27761265 cmd/tsconnect/wasm: add missing tstun.Wrapper.Start call
It's required as of the recent 5297bd2cff.

Updates #7894
Updates #9394 (sure would be nice)

Change-Id: Id6672408dd8a6c82dba71022c8763e589d789fcd
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-18 17:24:02 -07:00
Joe Tsai
cb00eac850 taildrop: disable TestResume (#9873)
This test is currently failing on Windows.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-18 15:29:06 -07:00
Joe Tsai
674beabc73 syncs: add Map.LoadFunc (#9869)
The LoadFunc loads a value and calls a user-provided function.
The utility of this method is to ensure that the map lock is held
while executing user-provided logic.
This allows us to solve TOCTOU bugs that would be nearly imposible
to the solve without this API.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-18 15:02:45 -07:00
James Tucker
afb72ecd73 .github/workflows: update golangci-lint
Updates #cleanup
Signed-off-by: James Tucker <james@tailscale.com>
2023-10-18 14:17:35 -07:00
Sonia Appasamy
851536044a client/web: add tests for authorizeRequest
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-18 16:44:25 -04:00
Maisem Ali
c3a8e63100 util/linuxfw: add additional nftable detection logic
We were previously using the netlink API to see if there are chains/rules that
already exist. This works fine in environments where there is either full
nftable support or no support at all. However, we have identified certain
environments which have partial nftable support and the only feasible way of
detecting such an environment is to try to create some of the chains that we
need.

This adds a check to create a dummy postrouting chain which is immediately
deleted. The goal of the check is to ensure we are able to use nftables and
that it won't error out later. This check is only done in the path where we
detected that the system has no preexisting nftable rules.

Updates #5621
Updates #8555
Updates #8762

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-18 13:39:55 -07:00
Maisem Ali
b47cf04624 util/linuxfw: fix broken tests
These tests were broken at HEAD. CI currently does not run these
as root, will figure out how to do that in a followup.

Updates #5621
Updates #8555
Updates #8762

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-18 13:39:55 -07:00
Joe Tsai
a8fbe284b2 taildrop: fix theoretical race condition (#9866)
WaitGroup.Wait should not be concurrently called WaitGroup.Add.
In other words, we should not start new goroutines after shutodwn is called.
Thus, add a conditional to check that shutdown has not been called
before starting off a new waitAndDelete goroutine.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-18 10:21:36 -07:00
License Updater
756a4c43b6 licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-10-18 09:43:23 -07:00
Joe Tsai
3f27087e9d taildrop: switch hashing to be streaming based (#9861)
While the previous logic was correct, it did not perform well.
Resuming is a dance between the client and server, where
1. the client requests hashes for a partial file,
2. the server then computes those hashes,
3. the client computes hashes locally and compares them.
4. goto 1 while the partial file still has data

While step 2 is running, the client is sitting idle.
While step 3 is running, the server is sitting idle.

By streaming over the block hash immediately after the server
computes it, the client can start checking the hash,
while the server works on the next hash (in a pipelined manner).
This performs dramatically better and also uses less memory
as we don't need to hold a list of hashes, but only need to
handle one hash at a time.

There are two detriments to this approach:
* The HTTP API relies on a JSON stream,
  which is not a standard REST-like pattern.
  However, since we implement both client and server,
  this is fine.
* While the stream is on-going, we hold an open file handle
  on the server side while the file is being hashed.
  On really slow streams, this could hold a file open forever.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
Co-authored-by: Rhea Ghosh <rhea@tailscale.com>
2023-10-17 17:53:40 -07:00
Joe Tsai
7971333603 ipn: fix localapi and peerapi protocol for taildrop resume (#9860)
Minor fixes:
* The branch for listing or hashing partial files was inverted.
* The host for peerapi call needs to be real (rather than bogus).
* Handle remote peers that don't support resuming.
* Make resume failures non-fatal (since we can still continue).

This was tested locally, end-to-end system test is future work.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
Co-authored-by: Rhea Ghosh <rhea@tailscale.com>
2023-10-17 16:14:47 -07:00
Andrew Lytvynov
77127a2494 clientupdate: fix background install for linux tarballs (#9852)
Two bug fixes:
1. when tailscale update is executed as root, `os.UserCacheDir` may
   return an error because `$XDG_CACHE_HOME` and `$HOME` are not set;
   fallback to `os.TempDir` in those cases
2. on some weird distros (like my EndeavourOS), `/usr/sbin` is just a
   symlink to `/usr/bin`; when we resolve `tailscale` binary path from
   `tailscaled`, allow `tailscaled` to be in either directory

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-17 14:24:06 -07:00
Sonia Appasamy
c27870e160 client/web: refactor authorizeRequest
Moves request authorization back into Server.serve to be run at
the start of any request. Fixes Synology unstable track bug where
client would get stuck unable to auth due to not rendering the
Synology redirect auth html on index.html load.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-17 17:16:18 -04:00
Joe Tsai
c2a551469c taildrop: implement asynchronous file deletion (#9844)
File resumption requires keeping partial files around for some time,
but we must still eventually delete them if never resumed.
Thus, we implement asynchronous file deletion, which could
spawn a background goroutine to delete the files.

We also use the same mechanism for deleting files on Windows,
where a file can't be deleted if there is still an open file handle.
We can enqueue those with the asynchronous file deleter as well.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-17 13:46:05 -07:00
Andrew Lytvynov
33bb2bbfe9 tailcfg,cmd/tailscale: add UrgentSecurityUpdate flag to ClientVersion (#9848)
This flag is used in clients to surface urgent updates more prominently.

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-17 11:04:44 -07:00
Irbe Krumina
cac290da87 cmd/k8s-operator: users can configure firewall mode for kube operator proxies (#9769)
* cmd/k8s-operator: users can configure operator to set firewall mode for proxies

Users can now pass PROXY_FIREWALL_MODE={nftables,auto,iptables} to operator to make it create ingress/egress proxies with that firewall mode

Also makes sure that if an invalid firewall mode gets configured, the operator will not start provisioning proxy resources, but will instead log an error and write an error event to the related Service.

Updates tailscale/tailscale#9310

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-10-17 18:05:02 +01:00
Tyler Smalley
ddb2a6eb8d cmd/tailscale: promote new serve/funnel CLI to be default (#9833)
The change is being kept to a minimum to make a revert easy if necessary. After the release, we will go back for a final cleanup.

updates #8489

Signed-off-by: Tyler Smalley <tyler@tailscale.com>
2023-10-17 09:32:17 -07:00
Maisem Ali
f53c3be07c cmd/k8s-operator: use our own container image instead of busybox
We already have sysctl in the `tailscale/tailscale` image, just use that.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-17 08:11:16 -07:00
Brad Fitzpatrick
1fc3573446 ipn/{conffile,ipnlocal}: start booting tailscaled from a config file w/ auth key
Updates #1412

Change-Id: Icd880035a31df59797b8379f4af19da5c4c453e2
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-17 07:12:49 -07:00
Brad Fitzpatrick
6ca8650c7b tstest/tstest: add t.Parallel that can be disabled by TS_SERIAL_TESTS=true
Updates #9841

Change-Id: I1b8f4d6e34ac8540e3b0455a7c79bd400e2721b7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-16 21:05:59 -07:00
Brad Fitzpatrick
4dec0c6eb9 tstest, tstest/integration, github/workflows: shard integration tests
Over four jobs for now.

Updates #cleanup

Change-Id: Ic2b1a739a454916893945a3f9efc480d6fcbd70b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-16 20:09:24 -07:00
Maisem Ali
e6ab7d3c14 cmd/testwrapper: parse args better
Previously we were just smushing together args and not trying
to parse the values at all. This resulted in the args to testwrapper
being limited and confusing.

This makes it so that testwrapper parses flags in the exact format as `go test`
command and passes them down in the provided order. It uses tesing.Init to
register flags that `go test` understands, however those are not the only
flags understood by `go test` (such as `-exec`) so we register these separately.

Updates tailscale/corp#14975

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-16 17:33:50 -07:00
Rhea Ghosh
9d3c6bf52e ipn/ipnlocal/peerapi: refactoring taildrop to just one endpoint (#9832)
Updates #14772

Signed-off-by: Rhea Ghosh <rhea@tailscale.com>
2023-10-16 14:35:11 -05:00
Maisem Ali
4899c2c1f4 cmd/containerboot: revert to using tailscale up
This partially reverts commits a61a9ab087
and 7538f38671 and fully reverts
4823a7e591.

The goal of that commit was to reapply known config whenever the
container restarts. However, that already happens when TS_AUTH_ONCE was
false (the default back then). So we only had to selectively reapply the
config if TS_AUTH_ONCE is true, this does exactly that.

This is a little sad that we have to revert to `tailscale up`, but it
fixes the backwards incompatibility problem.

Updates tailscale/tailscale#9539

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-16 12:00:44 -07:00
Andrew Lytvynov
b949e208bb ipn/ipnlocal: fix AllowsUpdate disable after enable (#9827)
The old code would always retain value `true` if it was set once, even
if you then change `prefs.AutoUpdate.Apply` to `false`.
Instead of using the previous value, use the default (envknob) value to
OR with.

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-16 10:54:56 -07:00
Brad Fitzpatrick
18bd98d35b cmd/tailscaled,*: add start of configuration file support
Updates #1412

Co-authored-by: Maisem Ali <maisem@tailscale.com>
Change-Id: I38d559c1784d09fc804f521986c9b4b548718f7d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-16 10:40:27 -07:00
Rhea Ghosh
71271e41d6 ipn/{ipnlocal/peerapi, localapi} initial taildrop resume api plumbing (#9798)
This change:
* adds a partial files peerAPI endpoint to get a list of partial files
* adds a helper function to extract the basename of a file
* updates the peer put peerAPI endpoint
* updates the file put localapi endpoint to allow resume functionality

Updates #14772

Signed-off-by: Rhea Ghosh <rhea@tailscale.com>
2023-10-16 12:36:31 -05:00
Brad Fitzpatrick
95faefd1f6 net/dnsfallback: disable recursive resolver for now
It seems to be implicated in a CPU consumption bug that's not yet
understood. Disable it until we understand.

Updates tailscale/corp#15261

Change-Id: Ia6d0c310da6464dda79a70fc3c18be0782812d3f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-16 09:47:08 -07:00
Andrew Lytvynov
8a5b02133d clientupdate: return ErrUnsupported for macSys clients (#9793)
The Sparkle-based update is not quite working yet. Make `NewUpdater`
return `ErrUnsupported` for it to avoid the proliferation of exceptions
up the stack.

Updates #755

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-16 09:14:14 -07:00
Flakes Updater
51078b6486 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-10-16 09:12:16 -07:00
Brad Fitzpatrick
7fd6cc3caa go.mod: bump alexbrainman/sspi
For https://github.com/alexbrainman/sspi/pull/13

Fixes #9131 (hopefully)

Change-Id: I27bb00bbf5e03850f65f18c45f15c4441cc54b23
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-16 09:10:27 -07:00
Sonia Appasamy
feabb34ea0 ipn/localapi: add debug-web-client endpoint
Debug endpoint for the web client's auth flow to talk back to the
control server. Restricted behind a feature flag on control.

We will either be removing this debug endpoint, or renaming it
before launching the web client updates.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-16 10:52:23 -04:00
Kristoffer Dalby
e06f2f1873 ipn/ipnlocal: change serial number policy to be PreferenceOption
This commit changes the PostureChecking syspolicy key to be a
PreferenceOption(user-defined, always, never) instead of Bool.

This aligns better with the defaults implementation on macOS allowing
CLI arguments to be read when user-defined or no defaults is set.

Updates #tailscale/tailscale/5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-10-16 16:01:54 +02:00
Denton Gentry
97ee3891f1 net/dns: use direct when NetworkManager has no systemd-resolved
Endeavour OS, at least, uses NetworkManager 1.44.2 and does
not use systemd-resolved behind the scenes at all. If we
find ourselves in that situation, return "direct" not
"systemd-resolved"

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

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-10-15 17:12:49 -07:00
Brad Fitzpatrick
56ebcd1ed4 .github/workflows: break up race builder a bit more
Move the compilation of everything to its own job too, separate
from test execution.

Updates #7894

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-14 19:28:31 -07:00
Brad Fitzpatrick
e89927de2b tsnet: fix data race in TestFallbackTCPHandler
Fixes #9805

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-14 19:12:43 -07:00
Brad Fitzpatrick
18e2936d25 github/workflows: move race tests to their own job
They're slow. Make them their own job that can run in parallel.

Also, only run them in race mode. No need to run them on 386
or non-race amd64.

Updates #7894

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-14 14:08:58 -07:00
Brad Fitzpatrick
c363b9055d tstest/integration: add tests for tun mode (requiring root)
Updates #7894

Change-Id: Iff0b07b21ae28c712dd665b12918fa28d6f601d0
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-14 13:52:30 -07:00
Brad Fitzpatrick
a6270826a3 wgengine/magicsock: fix data race regression in disco ping callbacks
Regression from c15997511d. The callback could be run multiple times
from different endpoints.

Fixes #9801

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-14 13:52:30 -07:00
Maisem Ali
5297bd2cff cmd/tailscaled,net/tstun: fix data race on start-up in TUN mode
Fixes #7894

Change-Id: Ice3f8019405714dd69d02bc07694f3872bb598b8

Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-14 08:54:30 -07:00
Brad Fitzpatrick
5c555cdcbb tstest/integration: set race flag when cross compiling, conditionally fail on race
Misc cleanups and things noticed while working on #7894 and pulled out
of a separate change. Submitting them on their own to not distract
from later changes.

Updates #7894

Change-Id: Ie9abc8b88f121c559aeeb7e74db2aa532eb84d3d
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-13 20:01:32 -07:00
Rhea Ghosh
8c7169105e ipn/{ipnlocal/peerapi, localapi}: cleaning up http statuses for consistency and readability (#9796)
Updates #cleanup

Signed-off-by: Rhea Ghosh <rhea@tailscale.com>
2023-10-13 17:40:10 -05:00
Joe Tsai
9cb6c5bb78 util/httphdr: add new package for parsing HTTP headers (#9797)
This adds support for parsing Range and Content-Range headers
according to RFC 7230. The package could be extended in the future
to handle other headers.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-13 15:38:22 -07:00
Andrew Lytvynov
af5a586463 ipn/ipnlocal: include AutoUpdate prefs in HostInfo.AllowsUpdate (#9792)
Updates #9260

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-13 11:14:23 -07:00
Claire Wang
754fb9a8a8 tailcfg: add tailnet field to register request (#9675)
Updates tailscale/corp#10967

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-10-13 14:13:41 -04:00
Joe Tsai
8f948638c5 taildrop: minor cleanups and fixes (#9786)
Perform the same m==nil check in Manager.{PartialFiles,HashPartialFile}
as we do in the other methods.

Fix HashPartialFile is properly handle a length of -1.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-13 10:21:15 -05:00
Joe Tsai
b1867eb23f taildrop: add logic for resuming partial files (#9785)
We add the following API:
* type FileChecksums
* type Checksum
* func Manager.PartialFiles
* func Manager.HashPartialFile
* func ResumeReader

The Manager methods provide the ability to query for partial files
and retrieve a list of checksums for a given partial file.
The ResumeReader function is a helper that wraps an io.Reader
to discard content that is identical locally and remotely.
The FileChecksums type represents the checksums of a file
and is safe to JSON marshal and send over the wire.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
Co-authored-by: Rhea Ghosh <rhea@tailscale.com>
2023-10-12 16:50:11 -07:00
Maisem Ali
24f322bc43 ipn/ipnlocal: do unexpired cert renewals in the background
We were eagerly doing a synchronous renewal of the cert while
trying to serve traffic. Instead of that, just do the cert
renewal in the background and continue serving traffic as long
as the cert is still valid.

This regressed in c1ecae13ab when
we introduced ARI support and were trying to make the experience
of `tailscale cert` better. However, that ended up regressing
the experience for tsnet as it would not always doing the renewal
synchronously.

Fixes #9783

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-12 16:02:45 -07:00
Joe Tsai
1a78f240b5 tstime: add DefaultClock (#9691)
In almost every single use of Clock, there is a default behavior
we want to use when the interface is nil,
which is to use the the standard time package.

The Clock interface exists only for testing,
and so tests that care about mocking time
can adequately plumb the the Clock down the stack
and through various data structures.

However, the problem with Clock is that there are many
situations where we really don't care about mocking time
(e.g., measuring execution time for a log message),
where making sure that Clock is non-nil is not worth the burden.
In fact, in a recent refactoring, the biggest pain point was
dealing with nil-interface panics when calling tstime.Clock methods
where mocking time wasn't even needed for the relevant tests.
This required wasted time carefully reviewing the code to
make sure that tstime.Clock was always populated,
and even then we're not statically guaranteed to avoid a nil panic.

Ideally, what we want are default methods on Go interfaces,
but such a language construct does not exist.
However, we can emulate that behavior by declaring
a concrete type that embeds the interface.
If the underlying interface value is nil,
it provides some default behavior (i.e., use StdClock).

This provides us a nice balance of two goals:
* We can plumb tstime.DefaultClock in all relevant places
  for use with mocking time in the tests that care.
* For all other logic that don't care about,
  we never need to worry about whether tstime.DefaultClock
  is nil or not. This is especially relevant in production code
  where we don't want to panic.

Longer-term, we may want to perform a large-scale change
where we rename Clock to ClockInterface
and rename DefaultClock to just Clock.

Updates #cleanup

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-12 16:01:17 -07:00
Naman Sood
7783a960e8 client/web: add metric for exit node advertising (#9781)
* client/web: add metric for exit node advertising

Updates tailscale/corp#15215

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

* client/web: use http request's context for IncrementCounter

Updates #cleanup

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

---------

Signed-off-by: Naman Sood <mail@nsood.in>
2023-10-12 17:02:20 -04:00
James Tucker
ce0830837d appctype: introduce a configuration schema for app connectors
Updates tailscale/corp#15043

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-12 10:49:23 -07:00
Joe Tsai
37c646d9d3 taildrop: improve the functionality and reliability of put (#9762)
Changes made:
* Move all HTTP related functionality from taildrop to ipnlocal.
* Add two arguments to taildrop.Manager.PutFile to specify
  an opaque client ID and a resume offset (both unused for now).
* Cleanup the logic of taildrop.Manager.PutFile
  to be easier to follow.
* Implement file conflict handling where duplicate files are renamed
  (e.g., "IMG_1234.jpg" -> "IMG_1234 (2).jpg").
* Implement file de-duplication where "renaming" a partial file
  simply deletes it if it already exists with the same contents.
* Detect conflicting active puts where a second concurrent put
  results in an error.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
Co-authored-by: Rhea Ghosh <rhea@tailscale.com>
2023-10-12 09:28:46 -07:00
Maisem Ali
1294b89792 cmd/k8s-operator: allow setting same host value for tls and ingress rules
We were too strict and required the user not specify the host field at all
in the ingress rules, but that degrades compatibility with existing helm charts.

Relax the constraint so that rule.Host can either be empty, or match the tls.Host[0]
value exactly.

Fixes #9548

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-12 06:40:52 -07:00
Maisem Ali
2d4f808a4c cmd/containerboot: fix time based serveConfig watcher
This broke in a last minute refactor and seems to have never worked.

Fixes #9686

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-12 06:36:40 -07:00
James Tucker
4abd470322 tailcfg: implement text encoding for ProtoPortRange
Updates tailscale/corp#15043
Signed-off-by: James Tucker <james@tailscale.com>
2023-10-11 23:59:42 -07:00
James Tucker
96f01a73b1 tailcfg: import ProtoPortRange for local use
Imported type and parsing, with minor modifications.

Updates tailscale/corp#15043

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-11 23:46:39 -07:00
Charlotte Brandhorst-Satzkorn
d62af8e643 words: flappy birds, but real life
These birds have been visually identified as having tails. Science
prevails.

Updates tailscale/corp#9599

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-10-11 22:39:30 -07:00
Maisem Ali
1cb9e33a95 cmd/k8s-operator: update env var in manifest to APISERVER_PROXY
Replace the deprecated var with the one in docs to avoid confusion.
Introduced in 335a5aaf9a.

Updates #8317
Fixes #9764

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-11 21:55:46 -07:00
James Tucker
c1ef55249a types/ipproto: import and test string parsing for ipproto
IPProto has been being converted to and from string formats in multiple
locations with variations in behavior. TextMarshaller and JSONMarshaller
implementations are now added, along with defined accepted and preferred
formats to centralize the logic into a single cross compatible
implementation.

Updates tailscale/corp#15043
Fixes tailscale/corp#15141

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-11 18:56:33 -07:00
Maisem Ali
319607625f ipn/ipnlocal: fix log spam from now expected paths
These log paths were actually unexpected until the refactor in
fe95d81b43. This moves the logs
to the callsites where they are actually unexpected.

Fixes #9670

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-11 14:53:58 -07:00
Maisem Ali
9d96e05267 net/packet: split off checksum munging into different pkg
The current structure meant that we were embedding netstack in
the tailscale CLI and in the GUIs. This removes that by isolating
the checksum munging to a different pkg which is only called from
`net/tstun`.

Fixes #9756

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-11 14:25:58 -07:00
Brad Fitzpatrick
8b630c91bc wgengine/filter: use slices.Contains in another place
We keep finding these.

Updates #cleanup

Change-Id: Iabc049b0f8da07341011356f0ecd5315c33ff548
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-11 14:16:52 -07:00
James 'zofrex' Sanderson
0a412eba40 words: Na na na na na na na na na na na na na na na na (#9753)
So gnarly!

Updates tailscale/corp#14698

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2023-10-11 22:15:53 +01:00
James Tucker
11348fbe72 util/nocasemaps: import nocasemaps from corp
This is a dependency of other code being imported later.

Updates tailscale/corp#15043

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-11 13:55:00 -07:00
Maisem Ali
fbfee6a8c0 cmd/containerboot: use linuxfw.NetfilterRunner
This migrates containerboot to reuse the NetfilterRunner used
by tailscaled instead of manipulating iptables rule itself.
This has the added advantage of now working with nftables and
we can potentially drop the `iptables` command from the container
image in the future.

Updates #9310

Co-authored-by: Irbe Krumina <irbe@tailscale.com>
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-11 12:23:52 -07:00
Sonia Appasamy
7a0de2997e client/web: remove unused context param from NewServer
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-11 15:06:26 -04:00
Maisem Ali
aad3584319 util/linuxfw: move fake runner into pkg
This allows using the fake runner in different packages
that need to manage filter rules.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-11 11:48:43 -07:00
Tom DNetto
fffafc65d6 tsnet: support registering fallback TCP flow handlers
For the app connector use-case, it doesnt make sense to use listeners, because then you would
need to register thousands of listeners (for each proto/service/port combo) to handle ranges.

Instead, we plumb through the TCPHandlerForFlow abstraction, to avoid using the listeners
abstraction that would end up being a bit messy.

Signed-off-by: Tom DNetto <tom@tailscale.com>
Updates: https://github.com/tailscale/corp/issues/15038
2023-10-11 11:29:29 -07:00
David Anderson
9f05018419 clientupdate/distsign: add new prod root signing key to keychain
Updates tailscale/corp#15179

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-10-11 09:20:17 -07:00
Galen Guyer
04a8b8bb8e net/dns: properly detect newer debian resolvconf
Tailscale attempts to determine if resolvconf or openresolv
is in use by running `resolvconf --version`, under the assumption
this command will error when run with Debian's resolvconf. This
assumption is no longer true and leads to the wrong commands being
run on newer versions of Debian with resolvconf >= 1.90. We can
now check if the returned version string starts with "Debian resolvconf"
if the command is successful.

Fixes #9218

Signed-off-by: Galen Guyer <galen@galenguyer.com>
2023-10-11 08:38:25 -07:00
Paul Scott
4e083e4548 util/cmpver: only consider ascii numerals (#9741)
Fixes #9740

Signed-off-by: Paul Scott <paul@tailscale.com>
2023-10-11 13:42:32 +01:00
Maisem Ali
78a083e144 types/ipproto: drop IPProto from IPProtoVersion
Based on https://github.com/golang/go/wiki/CodeReviewComments#package-names.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-10 23:44:48 -07:00
Maisem Ali
05a1f5bf71 util/linuxfw: move detection logic
Just a refactor to consolidate the firewall detection logic in a single
package so that it can be reused in a later commit by containerboot.

Updates #9310

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-10 20:29:24 -07:00
Maisem Ali
56c0a75ea9 tool/gocross: handle VERSION file not found
Fixes #9734

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-10-10 17:55:33 -07:00
James Tucker
ba6ec42f6d util/linuxfw: add missing input rule to the tailscale tun
Add an explicit accept rule for input to the tun interface, as a mirror
to the explicit rule to accept output from the tun interface.

The rule matches any packet in to our tun interface and accepts it, and
the rule is positioned and prioritized such that it should be evaluated
prior to conventional ufw/iptables/nft rules.

Updates #391
Fixes #7332
Updates #9084

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-10 17:22:47 -07:00
Andrew Lytvynov
677d486830 clientupdate: abort if current version is newer than latest (#9733)
This is only relevant for unstable releases and local builds. When local
version is newer than upstream, abort release.

Also, re-add missing newlines in output that were missed in
https://github.com/tailscale/tailscale/pull/9694.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-10 17:01:44 -07:00
Will Norris
7f08bddfe1 tailcfg: add type for web client auth response
This will be returned from the upcoming control endpoints for doing web
client session authentication.

Updates tailscale/corp#14335

Signed-off-by: Will Norris <will@tailscale.com>
2023-10-10 15:13:50 -07:00
Flakes Updater
00977f6de9 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-10-10 14:43:58 -07:00
License Updater
0ccfcb515c licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-10-10 14:43:50 -07:00
Brad Fitzpatrick
3749a3bbbb go.toolchain.rev: bump for CVE-2023-39325
Updates tailscale/corp#15165

Change-Id: Ib001cfb44eb3e6d735dfece9bd3ae9eea13048c9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-10 11:46:38 -07:00
Brad Fitzpatrick
6b1ed732df go.mod: bump x/net to 0.17 for CVE-2023-39325
https://go.googlesource.com/net/+/b225e7ca6dde1ef5a5ae5ce922861bda011cfabd

Updates tailscale/corp#15165

Change-Id: Ia8b5e16b1acfe1b2400d321034b41370396f70e2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-10 11:25:24 -07:00
Brad Fitzpatrick
70de16bda7 ipn/localapi: make whois take IP or IP:port as documented, fix capmap netstack lookup
The whois handler was documented as taking IP (e.g. 100.101.102.103)
or IP:port (e.g. usermode 127.0.0.1:1234) but that got broken at some point
and we started requiring a port always. Fix that.

Also, found in the process of adding tests: fix the CapMap lookup in
userspace mode (it was always returning the caps of 127.0.0.1 in
userspace mode). Fix and test that too.

Updates #9714

Change-Id: Ie9a59744286522fa91c4b70ebe89a1e94dbded26
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-10 11:05:04 -07:00
Kristoffer Dalby
7f540042d5 ipn/ipnlocal: use syspolicy to determine collection of posture data
Updates #5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-10-10 12:04:34 +02:00
Kristoffer Dalby
d0b8bdf8f7 posture: add get serial support for macOS
Updates #5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-10-09 14:46:59 +02:00
Kristoffer Dalby
9eedf86563 posture: add get serial support for Windows/Linux
This commit adds support for getting serial numbers from SMBIOS
on Windows/Linux (and BSD) using go-smbios.

Updates #5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-10-09 13:50:34 +02:00
Val
249edaa349 wgengine/magicsock: add probed MTU metrics
Record the number of MTU probes sent, the total bytes sent, the number of times
we got a successful return from an MTU probe of a particular size, and the max
MTU recorded.

Updates #311

Signed-off-by: Val <valerie@tailscale.com>
2023-10-09 01:57:12 -07:00
Val
893bdd729c disco,net/tstun,wgengine/magicsock: probe peer MTU
Automatically probe the path MTU to a peer when peer MTU is enabled, but do not
use the MTU information for anything yet.

Updates #311

Signed-off-by: Val <valerie@tailscale.com>
2023-10-09 01:57:12 -07:00
Kristoffer Dalby
b4e587c3bd tailcfg,ipn: add c2n endpoint for posture identity
Updates #5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-10-09 08:15:38 +02:00
Kristoffer Dalby
9593cd3871 posture: add get serial stub for all platforms
Updates #5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-10-09 08:15:38 +02:00
Kristoffer Dalby
623926a25d cmd/tailscale: add --posture-checking flag to set
Updates #5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-10-09 08:15:38 +02:00
Kristoffer Dalby
886917c42b ipn: add PostureChecks to Prefs
Updates #5902

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-10-09 08:15:38 +02:00
Simon Leonhardt
553f657248 sniproxy allows configuration of hostname
Signed-off-by: Simon Leonhardt <simon@controlzee.com>
2023-10-08 15:53:52 -07:00
Brad Fitzpatrick
6f36f8842c cmd/tailscale, magicsock: add debug command to flip DERP homes
For testing netmap patchification server-side.

Updates #1909

Change-Id: Ib1d784bd97b8d4a31e48374b4567404aae5280cc
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-06 20:48:13 -07:00
James Tucker
13767e5108 docs/sysv: add a sysv style init script
The script depends on a sufficiently recent start-stop-daemon as to
provide the `-m` and `--remove-pidfile` flags.

Updates #9502

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-06 19:35:58 -07:00
Brad Fitzpatrick
f991c8a61f tstest: make ResourceCheck panic on parallel tests
To find potential flakes earlier.

Updates #deflake-effort

Change-Id: I52add6111d660821c3a23d4b1dd032821344bc48
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-06 19:12:34 -07:00
James Tucker
498f7ec663 syncs: add Map.LoadOrInit for lazily initialized values
I was reviewing some code that was performing this by hand, and wanted
to suggest using syncs.Map, however as the code in question was
allocating a non-trivial structure this would be necessary to meet the
target.

Updates #cleanup

Signed-off-by: James Tucker <james@tailscale.com>
2023-10-06 17:06:11 -07:00
Joe Tsai
e4cb83b18b taildrop: document and cleanup the package (#9699)
Changes made:
* Unexport declarations specific to internal taildrop functionality.
* Document all exported functionality.
* Move TestRedactErr to the taildrop package.
* Rename and invert Handler.DirectFileDoFinalRename as AvoidFinalRename.

Updates tailscale/corp#14772

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-10-06 15:41:14 -07:00
Andrew Lytvynov
e6aa7b815d clientupdate,cmd/tailscale/cli: use cli.Stdout/Stderr (#9694)
In case cli.Stdout/Stderr get overriden, all CLI output should use them
instead of os.Stdout/Stderr. Update the `update` command to follow this
pattern.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-10-06 12:00:15 -07:00
Brad Fitzpatrick
b7988b3825 api.md: remove clientConnectivity.derp field
We don't actually send this. It's always been empty.

Updates tailscale/corp#13400

Change-Id: I99b3d7a355fca17d2159bf81ede5be4ddd4b9dc9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-06 09:29:42 -07:00
Rhea Ghosh
557ddced6c {ipn/ipnlocal, taildrop}: move put logic to taildrop (#9680)
Cleaning up taildrop logic for sending files.

Updates tailscale/corp#14772

Signed-off-by: Rhea Ghosh <rhea@tailscale.com>
Co-authored-by: Joe Tsai <joetsai@digital-static.net>
2023-10-06 09:47:03 -05:00
328 changed files with 26759 additions and 6379 deletions

28
.github/workflows/checklocks.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: checklocks
on:
push:
branches:
- main
pull_request:
paths:
- '**/*.go'
- '.github/workflows/checklocks.yml'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
checklocks:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Build checklocks
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
- name: Run checklocks vet
# TODO: remove || true once we have applied checklocks annotations everywhere.
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true

View File

@@ -31,10 +31,10 @@ jobs:
cache: false
- name: golangci-lint
# Note: this is the 'v3' tag as of 2023-04-17
# Note: this is the 'v3' tag as of 2023-08-14
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
with:
version: v1.52.2
version: v1.54.2
# Show only new issues if it's a pull request.
only-new-issues: true

24
.github/workflows/kubemanifests.yaml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: "Kubernetes manifests"
on:
pull_request:
paths:
- './cmd/k8s-operator/'
- '.github/workflows/kubemanifests.yaml'
# Cancel workflow run if there is a newer push to the same PR for which it is
# running
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
testchart:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Build and lint Helm chart
run: |
eval `./tool/go run ./cmd/mkversion`
./tool/helm package --app-version="${VERSION_SHORT}" --version=${VERSION_SHORT} './cmd/k8s-operator/deploy/chart'
./tool/helm lint "tailscale-operator-${VERSION_SHORT}.tgz"

View File

@@ -22,8 +22,7 @@ on:
- "main"
- "release-branch/*"
pull_request:
branches:
- "*"
# all PRs on all branches
merge_group:
branches:
- "main"
@@ -39,14 +38,42 @@ concurrency:
cancel-in-progress: true
jobs:
race-root-integration:
runs-on: ubuntu-22.04
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
include:
- shard: '1/4'
- shard: '2/4'
- shard: '3/4'
- shard: '4/4'
steps:
- name: checkout
uses: actions/checkout@v4
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: integration tests as root
run: PATH=$PWD/tool:$PATH /tmp/testwrapper -exec "sudo -E" -race ./tstest/integration/
env:
TS_TEST_SHARD: ${{ matrix.shard }}
test:
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
include:
- goarch: amd64
coverflags: "-coverprofile=/tmp/coverage.out"
- goarch: amd64
buildflags: "-race"
shard: '1/3'
- goarch: amd64
buildflags: "-race"
shard: '2/3'
- goarch: amd64
buildflags: "-race"
shard: '3/3'
- goarch: "386" # thanks yaml
runs-on: ubuntu-22.04
steps:
@@ -70,6 +97,7 @@ jobs:
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-
- name: build all
if: matrix.buildflags == '' # skip on race builder
run: ./tool/go build ${{matrix.buildflags}} ./...
env:
GOARCH: ${{ matrix.goarch }}
@@ -91,9 +119,15 @@ jobs:
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: test all
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ${{matrix.coverflags}} ./... ${{matrix.buildflags}}
env:
GOARCH: ${{ matrix.goarch }}
TS_TEST_SHARD: ${{ matrix.shard }}
- name: Publish to coveralls.io
if: matrix.coverflags != '' # only publish results if we've tracked coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: /tmp/coverage.out
- name: bench all
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
env:
@@ -162,7 +196,17 @@ jobs:
HOME: "/tmp"
TMPDIR: "/tmp"
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
race-build:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v4
- name: build all
run: ./tool/go install -race ./cmd/...
- name: build tests
run: ./tool/go test -race -exec=true ./...
cross: # cross-compile checks, build only.
strategy:
fail-fast: false # don't abort the entire matrix if one element fails

View File

@@ -0,0 +1,52 @@
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: |
./tool/go version # build gocross if needed using regular GOPROXY
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.51.0
1.55.0

4
api.md
View File

@@ -209,10 +209,6 @@ You can also [list all devices in the tailnet](#list-tailnet-devices) to get the
"192.68.0.21:59128"
],
// derp (string) is the IP:port of the DERP server currently being used.
// Learn about DERP servers at https://tailscale.com/kb/1232/.
"derp":"",
// mappingVariesByDestIP (boolean) is 'true' if the host's NAT mappings
// vary based on the destination IP.
"mappingVariesByDestIP":false,

221
appc/appconnector.go Normal file
View File

@@ -0,0 +1,221 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package appc implements App Connectors.
// An AppConnector provides DNS domain oriented routing of traffic. An App
// Connector becomes a DNS server for a peer, authoritative for the set of
// configured domains. DNS resolution of the target domain triggers dynamic
// publication of routes to ensure that traffic to the domain is routed through
// the App Connector.
package appc
import (
"net/netip"
"slices"
"strings"
"sync"
xmaps "golang.org/x/exp/maps"
"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
// newly discovered routes that need to be served through the AppConnector.
type RouteAdvertiser interface {
// AdvertiseRoute adds a new route advertisement if the route is not already
// being advertised.
AdvertiseRoute(netip.Prefix) error
}
// AppConnector is an implementation of an AppConnector that performs
// its function as a subsystem inside of a tailscale node. At the control plane
// side App Connector routing is configured in terms of domains rather than IP
// addresses.
// The AppConnectors responsibility inside tailscaled is to apply the routing
// and domain configuration as supplied in the map response.
// DNS requests for configured domains are observed. If the domains resolve to
// routes not yet served by the AppConnector the local node configuration is
// updated to advertise the new route.
type AppConnector struct {
logf logger.Logf
routeAdvertiser RouteAdvertiser
// mu guards the fields that follow
mu sync.Mutex
// 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.
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
return &AppConnector{
logf: logger.WithPrefix(logf, "appc: "),
routeAdvertiser: routeAdvertiser,
}
}
// 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. 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 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)
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)
}
// 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.
func (e *AppConnector) Domains() views.Slice[string] {
e.mu.Lock()
defer e.mu.Unlock()
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 {
drCopy[k] = append(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
// advised to advertise the discovered route.
func (e *AppConnector) ObserveDNSResponse(res []byte) {
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return
}
if err := p.SkipAllQuestions(); err != nil {
return
}
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
break
}
if err != nil {
return
}
if h.Class != dnsmessage.ClassINET {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
domain := h.Name.String()
if len(domain) == 0 {
return
}
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
}
continue
}
var addr netip.Addr
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return
}
addr = netip.AddrFrom4(r.A)
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return
}
addr = netip.AddrFrom16(r.AAAA)
default:
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
if slices.Contains(addrs, addr) {
continue
}
// TODO(raggi): check for existing prefixes
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
e.logf("failed to advertise route for %v: %v", addr, err)
continue
}
e.logf("[v2] advertised route for %v: %v", domain, addr)
e.mu.Lock()
e.domains[domain] = append(addrs, addr)
e.mu.Unlock()
}
}

162
appc/appconnector_test.go Normal file
View File

@@ -0,0 +1,162 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package appc
import (
"net/netip"
"reflect"
"slices"
"testing"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/util/must"
)
func TestUpdateDomains(t *testing.T) {
a := NewAppConnector(t.Logf, nil)
a.UpdateDomains([]string{"example.com"})
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
addr := netip.MustParseAddr("192.0.0.8")
a.domains["example.com"] = append(a.domains["example.com"], addr)
a.UpdateDomains([]string{"example.com"})
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// domains are explicitly downcased on set.
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestDomainRoutes(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
a.UpdateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
want := map[string][]netip.Addr{
"example.com": {netip.MustParseAddr("192.0.0.8")},
}
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
}
}
func TestObserveDNSResponse(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
// a has no domains configured, so it should not advertise any routes
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.routes, ([]netip.Prefix)(nil); !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
a.UpdateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// don't re-advertise routes that have already been advertised
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if !slices.Equal(rc.routes, wantRoutes) {
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
}
}
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)
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
b.EnableCompression()
b.StartAnswers()
switch addr.BitLen() {
case 32:
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: addr.As4(),
},
)
case 128:
b.AAAAResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AAAAResource{
AAAA: addr.As16(),
},
)
default:
panic("invalid address length")
}
return must.Get(b.Finish())
}
// routeCollector is a test helper that collects the list of routes advertised
type routeCollector struct {
routes []netip.Prefix
}
// routeCollector implements RouteAdvertiser
var _ RouteAdvertiser = (*routeCollector)(nil)
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
rc.routes = append(rc.routes, pfx)
return nil
}

View File

@@ -40,3 +40,12 @@ type SetPushDeviceTokenRequest struct {
// PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent).
PushDeviceToken string
}
// ReloadConfigResponse is the response to a LocalAPI reload-config request.
//
// There are three possible outcomes: (false, "") if no config mode in use,
// (true, "") on success, or (false, "error message") on failure.
type ReloadConfigResponse struct {
Reloaded bool // whether the config was reloaded
Err string // any error message
}

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
@@ -1244,6 +1264,22 @@ func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProf
return current, all, err
}
// ReloadConfig reloads the config file, if possible.
func (lc *LocalClient) ReloadConfig(ctx context.Context) (ok bool, err error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/reload-config", 200, nil)
if err != nil {
return
}
res, err := decodeJSON[apitype.ReloadConfigResponse](body)
if err != nil {
return
}
if res.Err != "" {
return false, errors.New(res.Err)
}
return res.Reloaded, nil
}
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
// profile is not assigned an ID until it is persisted after a successful login.
// In order to login to the new profile, the user must call LoginInteractive.
@@ -1358,6 +1394,21 @@ func (lc *LocalClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt)
}, nil
}
// CheckUpdate returns a tailcfg.ClientVersion indicating whether or not an update is available
// to be installed via the LocalAPI. In case the LocalAPI can't install updates, it returns a
// ClientVersion that says that we are up to date.
func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
body, err := lc.get200(ctx, "/localapi/v0/update/check")
if err != nil {
return nil, err
}
cv, err := decodeJSON[tailcfg.ClientVersion](body)
if err != nil {
return nil, err
}
return &cv, nil
}
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
// It's returned by LocalClient.WatchIPNBus.
//

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)

233
client/web/auth.go Normal file
View File

@@ -0,0 +1,233 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"net/http"
"net/url"
"strings"
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
)
const (
sessionCookieName = "TS-Web-Session"
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
)
// browserSession holds data about a user's browser session
// on the full management web client.
type browserSession struct {
// ID is the unique identifier for the session.
// It is passed in the user's "TS-Web-Session" browser cookie.
ID string
SrcNode tailcfg.NodeID
SrcUser tailcfg.UserID
AuthID string // from tailcfg.WebClientAuthResponse
AuthURL string // from tailcfg.WebClientAuthResponse
Created time.Time
Authenticated bool
}
// isAuthorized reports true if the given session is authorized
// to be used by its associated user to access the full management
// web client.
//
// isAuthorized is true only when s.Authenticated is true (i.e.
// the user has authenticated the session) and the session is not
// expired.
// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isAuthorized(now time.Time) bool {
switch {
case s == nil:
return false
case !s.Authenticated:
return false // awaiting auth
case s.isExpired(now):
return false // expired
}
return true
}
// isExpired reports true if s is expired.
// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isExpired(now time.Time) bool {
return !s.Created.IsZero() && now.After(s.expires())
}
// expires reports when the given session expires.
func (s *browserSession) expires() time.Time {
return s.Created.Add(sessionCookieExpiry)
}
var (
errNoSession = errors.New("no-browser-session")
errNotUsingTailscale = errors.New("not-using-tailscale")
errTaggedRemoteSource = errors.New("tagged-remote-source")
errTaggedLocalSource = errors.New("tagged-local-source")
errNotOwner = errors.New("not-owner")
)
// getSession retrieves the browser session associated with the request,
// if one exists.
//
// An error is returned in any of the following cases:
//
// - (errNotUsingTailscale) The request was not made over tailscale.
//
// - (errNoSession) The request does not have a session.
//
// - (errTaggedRemoteSource) The source is remote (another node) and tagged.
// Users must use their own user-owned devices to manage other nodes'
// web clients.
//
// - (errTaggedLocalSource) The source is local (the same node) and tagged.
// Tagged nodes can only be remotely managed, allowing ACLs to dictate
// access to web clients.
//
// - (errNotOwner) The source is not the owner of this client (if the
// client is user-owned). Only the owner is allowed to manage the
// node via the web client.
//
// If no error is returned, the browserSession is always non-nil.
// getTailscaleBrowserSession does not check whether the session has been
// authorized by the user. Callers can use browserSession.isAuthorized.
//
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
switch {
case whoIsErr != nil:
return nil, nil, errNotUsingTailscale
case statusErr != nil:
return nil, whoIs, statusErr
case status.Self == nil:
return nil, whoIs, errors.New("missing self node in tailscale status")
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
return nil, whoIs, errTaggedLocalSource
case whoIs.Node.IsTagged():
return nil, whoIs, errTaggedRemoteSource
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
return nil, whoIs, errNotOwner
}
srcNode := whoIs.Node.ID
srcUser := whoIs.UserProfile.ID
cookie, err := r.Cookie(sessionCookieName)
if errors.Is(err, http.ErrNoCookie) {
return nil, whoIs, errNoSession
} else if err != nil {
return nil, whoIs, err
}
v, ok := s.browserSessions.Load(cookie.Value)
if !ok {
return nil, whoIs, errNoSession
}
session := v.(*browserSession)
if session.SrcNode != srcNode || session.SrcUser != srcUser {
// In this case the browser cookie is associated with another tailscale node.
// Maybe the source browser's machine was logged out and then back in as a different node.
// Return errNoSession because there is no session for this user.
return nil, whoIs, errNoSession
} else if session.isExpired(s.timeNow()) {
// Session expired, remove from session map and return errNoSession.
s.browserSessions.Delete(session.ID)
return nil, whoIs, errNoSession
}
return session, whoIs, nil
}
// newSession creates a new session associated with the given source user/node,
// and stores it back to the session cache. Creating of a new session includes
// generating a new auth URL from the control server.
func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
sid, err := s.newSessionID()
if err != nil {
return nil, err
}
session := &browserSession{
ID: sid,
SrcNode: src.Node.ID,
SrcUser: src.UserProfile.ID,
Created: s.timeNow(),
}
if s.controlSupportsCheckMode(ctx) {
// control supports check mode, so get a new auth URL and return.
a, err := s.newAuthURL(ctx, src.Node.ID)
if err != nil {
return nil, err
}
session.AuthID = a.ID
session.AuthURL = a.URL
} else {
// control does not support check mode, so there is no additional auth we can do.
session.Authenticated = true
}
s.browserSessions.Store(sid, session)
return session, nil
}
// controlSupportsCheckMode returns whether the current control server supports web client check mode, to verify a user's identity.
// We assume that only "tailscale.com" control servers support check mode.
// This allows the web client to be used with non-standard control servers.
// If an error occurs getting the control URL, this method returns true to fail closed.
//
// TODO(juanfont/headscale#1623): adjust or remove this when headscale supports check mode.
func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
prefs, err := s.lc.GetPrefs(ctx)
if err != nil {
return true
}
controlURL, err := url.Parse(prefs.ControlURL)
if err != nil {
return true
}
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
}
// awaitUserAuth blocks until the given session auth has been completed
// by the user on the control server, then updates the session cache upon
// completion. An error is returned if control auth failed for any reason.
func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
if session.isAuthorized(s.timeNow()) {
return nil // already authorized
}
a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
if err != nil {
// Clean up the session. Doing this on any error from control
// server to avoid the user getting stuck with a bad session
// cookie.
s.browserSessions.Delete(session.ID)
return err
}
if a.Complete {
session.Authenticated = a.Complete
s.browserSessions.Store(session.ID, session)
}
return nil
}
func (s *Server) newSessionID() (string, error) {
raw := make([]byte, 16)
for i := 0; i < 5; i++ {
if _, err := rand.Read(raw); err != nil {
return "", err
}
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
if _, ok := s.browserSessions.Load(cookie); !ok {
return cookie, nil
}
}
return "", errors.New("too many collisions generating new session; please refresh page")
}

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
<link rel="stylesheet" type="text/css" href="/src/index.css" />
<link rel="preload" as="font" href="/src/assets/fonts/Inter.var.latin.woff2" type="font/woff2" crossorigin />
</head>
<body>
<noscript>

View File

@@ -8,17 +8,21 @@
},
"private": true,
"dependencies": {
"@radix-ui/react-collapsible": "^1.0.3",
"@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",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"postcss": "^8.4.27",
"eslint": "^8.23.1",
"eslint-config-react-app": "^7.0.1",
"postcss": "^8.4.31",
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
@@ -32,11 +36,26 @@
"scripts": {
"build": "vite build",
"start": "vite",
"lint": "tsc --noEmit",
"lint": "tsc --noEmit && eslint 'src/**/*.{ts,tsx,js,jsx}'",
"test": "vitest",
"format": "prettier --write 'src/**/*.{ts,tsx}'",
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
},
"eslintConfig": {
"extends": [
"react-app"
],
"plugins": [
"react-hooks"
],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
},
"settings": {
"projectRoot": "client/web/package.json"
}
},
"prettier": {
"semi": false,
"printWidth": 80

View File

@@ -9,6 +9,7 @@ package web
import (
"crypto/tls"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
@@ -18,21 +19,17 @@ import (
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
// are authorized to use the web client.
// It reports true if the request is authorized to continue, and false otherwise.
// authorizeQNAP manages writing out any relevant authorization errors to the
// ResponseWriter itself.
func authorizeQNAP(w http.ResponseWriter, r *http.Request) (ok bool) {
// If the user is not authorized to use the client, an error is returned.
func authorizeQNAP(r *http.Request) (authorized bool, err error) {
_, resp, err := qnapAuthn(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return false
return false, err
}
if resp.IsAdmin == 0 {
http.Error(w, "user is not an admin", http.StatusForbidden)
return false
return false, errors.New("user is not an admin")
}
return true
return true, nil
}
type qnapAuthResponse struct {

View File

@@ -1,4 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
let csrfToken: string
let synoToken: string | undefined // required for synology API requests
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
// apiFetch wraps the standard JS fetch function with csrf header
@@ -9,15 +13,19 @@ 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> {
const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams(params)
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
if (synoToken) {
nextParams.set("SynoToken", synoToken)
} else {
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
}
const search = nextParams.toString()
const url = `api${endpoint}${search ? `?${search}` : ""}`
@@ -62,6 +70,10 @@ function updateCsrfToken(r: Response) {
}
}
export function setSynoToken(token?: string) {
synoToken = token
}
export function setUnraidCsrfToken(token?: string) {
unraidCsrfToken = token
}

Binary file not shown.

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,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 704 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="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_14876_118476)">
<path d="M8.00065 14.6667C11.6825 14.6667 14.6673 11.6819 14.6673 8.00004C14.6673 4.31814 11.6825 1.33337 8.00065 1.33337C4.31875 1.33337 1.33398 4.31814 1.33398 8.00004C1.33398 11.6819 4.31875 14.6667 8.00065 14.6667Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 4V8L10.6667 9.33333" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_14876_118476">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 678 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

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="M10 4.16663V15.8333" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.16602 10H15.8327" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 329 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

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

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

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 9L9 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9L15 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -0,0 +1,28 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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,124 +1,128 @@
import React from "react"
import { Footer, Header, IP, State } from "src/components/legacy"
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React, { useEffect } from "react"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
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 LoginView from "src/components/views/login-view"
import SSHView from "src/components/views/ssh-view"
import SubnetRouterView from "src/components/views/subnet-router-view"
import { UpdatingView } from "src/components/views/updating-view"
import useAuth, { AuthResponse } from "src/hooks/auth"
import useNodeData, { 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 { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
import { Link, Route, Router, Switch, useLocation } from "wouter"
export default function App() {
// TODO(sonia): use isPosting value from useNodeData
// to fill loading states.
const { data, refreshData, updateNode } = useNodeData()
const { data: auth, loading: loadingAuth, newSession } = useAuth()
if (!data) {
// TODO(sonia): add a loading view
return <div className="text-center py-14">Loading...</div>
}
const needsLogin = data?.Status === "NeedsLogin" || data?.Status === "NoState"
return !needsLogin &&
(data.DebugMode === "login" || data.DebugMode === "full") ? (
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
{data.DebugMode === "login" ? (
<LoginView {...data} />
return (
<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
) : (
<ManageView {...data} />
<WebClient auth={auth} newSession={newSession} />
)}
<Footer className="mt-20" licensesURL={data.LicensesURL} />
</div>
) : (
// Legacy client UI
<div className="py-14">
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
<IP data={data} />
<State data={data} updateNode={updateNode} />
</main>
<Footer licensesURL={data.LicensesURL} />
</div>
</main>
)
}
function LoginView(props: NodeData) {
return (
function WebClient({
auth,
newSession,
}: {
auth: AuthResponse
newSession: () => Promise<void>
}) {
const { data, refreshData, nodeUpdaters } = useNodeData()
useEffect(() => {
refreshData()
}, [auth, refreshData])
return !data ? (
<div className="text-center py-14">Loading...</div>
) : data.Status === "NeedsLogin" ||
data.Status === "NoState" ||
data.Status === "Stopped" ? (
// Client not on a tailnet, render login.
<LoginView data={data} refreshData={refreshData} />
) : (
// Otherwise render the new web client.
<>
<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={props.Profile.ProfilePicURL} />
<div className="font-medium">
<div className="text-neutral-500 text-xs uppercase tracking-wide">
Owned by
</div>
<div className="text-neutral-800 text-sm leading-tight">
{/* TODO(sonia): support tagged node profile view more eloquently */}
{props.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]">
{props.DeviceName}
</div>
<div className="text-sm leading-tight">{props.IP}</div>
</div>
</div>
<button className="button button-blue ml-6">Access</button>
</div>
</div>
<Router base={data.URLPrefix}>
<Header node={data} auth={auth} newSession={newSession} />
<Switch>
<Route path="/">
<HomeView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
</Route>
<Route path="/details">
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
</Route>
<Route path="/subnets">
<SubnetRouterView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
</Route>
<Route path="/ssh">
<SSHView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
</Route>
<Route path="/serve">{/* TODO */}Share local content</Route>
<Route path="/update">
<UpdatingView
versionInfo={data.ClientVersion}
currentVersion={data.IPNVersion}
/>
</Route>
<Route>
<h2 className="mt-8">Page not found</h2>
</Route>
</Switch>
</Router>
</>
)
}
function ManageView(props: NodeData) {
return (
<div className="px-5">
<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>
</div>
)
}
function Header({
node,
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => Promise<void>
}) {
const [loc] = useLocation()
function ProfilePic({ url }: { url: string }) {
return (
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{url ? (
<div
className="w-8 h-8 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="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 !== "/" && loc !== "/update" && (
<Link
to="/"
className="text-indigo-500 font-medium leading-snug block mb-[10px]"
>
&larr; Back to {node.DeviceName}
</Link>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import { NodeData } from "src/hooks/node-data"
/**
* AdminContainer renders its contents only if the node's control
* server has an admin panel.
*
* TODO(sonia,will): Similarly, this could also hide the contents
* if the viewing user is a non-admin.
*/
export function AdminContainer({
node,
children,
className,
}: {
node: NodeData
children: React.ReactNode
className?: string
}) {
if (!node.ControlAdminURL.includes("tailscale.com")) {
// Admin panel only exists on Tailscale control servers.
return null
}
return <div className={className}>{children}</div>
}
/**
* AdminLink renders its contents wrapped in a link to the node's control
* server admin panel.
*
* AdminLink is meant for use only inside of a AdminContainer component,
* to avoid rendering a link when the node's control server does not have
* an admin panel.
*/
export function AdminLink({
node,
children,
path,
}: {
node: NodeData
children: React.ReactNode
path: string // admin path, e.g. "/settings/webhooks"
}) {
return (
<a
href={`${node.ControlAdminURL}${path}`}
className="link"
target="_blank"
rel="noreferrer"
>
{children}
</a>
)
}

View File

@@ -0,0 +1,533 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useMemo, useRef, useState } from "react"
import { ReactComponent as Check } from "src/assets/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import useExitNodes, {
ExitNode,
noExitNode,
runAsExitNode,
trimDNSSuffix,
} from "src/hooks/exit-nodes"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import Popover from "src/ui/popover"
import SearchInput from "src/ui/search-input"
export default function ExitNodeSelector({
className,
node,
nodeUpdaters,
disabled,
}: {
className?: string
node: NodeData
nodeUpdaters: NodeUpdaters
disabled?: boolean
}) {
const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
const handleSelect = useCallback(
(n: ExitNode) => {
setOpen(false)
if (n.ID === selected.ID) {
return // no update
}
const old = selected
setSelected(n) // optimistic UI update
nodeUpdaters.postExitNode(n).catch(() => setSelected(old))
},
[nodeUpdaters, selected]
)
const [
none, // not using exit nodes
advertising, // advertising as exit node
using, // using another exit node
] = useMemo(
() => [
selected.ID === noExitNode.ID,
selected.ID === runAsExitNode.ID,
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
],
[selected]
)
return (
<Popover
open={disabled ? false : open}
onOpenChange={setOpen}
side="bottom"
sideOffset={5}
align="start"
alignOffset={8}
content={
<ExitNodeSelectorInner
node={node}
selected={selected}
onSelect={handleSelect}
/>
}
asChild
>
<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.Location && (
<>
<CountryFlag code={selected.Location.CountryCode} />{" "}
</>
)}
{selected === runAsExitNode
? "Running as exit node"
: selected.Name}
</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", {
"bg-orange-400": advertising,
"bg-indigo-400": using,
"cursor-not-allowed": disabled,
})}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleSelect(noExitNode)
}}
disabled={disabled}
>
Disable
</button>
)}
</div>
</Popover>
)
}
function toSelectedExitNode(data: NodeData): ExitNode {
if (data.AdvertisingExitNode) {
return runAsExitNode
}
if (data.UsingExitNode) {
// TODO(sonia): also use online status
const node = { ...data.UsingExitNode }
if (node.Location) {
// For mullvad nodes, use location as name.
node.Name = `${node.Location.Country}: ${node.Location.City}`
} else {
// Otherwise use node name w/o DNS suffix.
node.Name = trimDNSSuffix(node.Name, data.TailnetName)
}
return node
}
return noExitNode
}
function ExitNodeSelectorInner({
node,
selected,
onSelect,
}: {
node: NodeData
selected: ExitNode
onSelect: (node: ExitNode) => void
}) {
const [filter, setFilter] = useState<string>("")
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
const listRef = useRef<HTMLDivElement>(null)
const hasNodes = useMemo(
() => exitNodes.find((n) => n.nodes.length > 0),
[exitNodes]
)
return (
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
<SearchInput
name="exit-node-search"
inputClassName="w-full px-4 py-2"
autoCorrect="off"
autoComplete="off"
autoCapitalize="off"
placeholder="Search exit nodes…"
value={filter}
onChange={(e) => {
// Jump list to top when search value changes.
listRef.current?.scrollTo(0, 0)
setFilter(e.target.value)
}}
/>
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
<div
ref={listRef}
className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll"
>
{hasNodes ? (
exitNodes.map(
(group) =>
group.nodes.length > 0 && (
<div
key={group.id}
className="pb-1 mb-1 border-b last:border-b-0 last:mb-0"
>
{group.name && (
<div className="px-4 py-2 text-neutral-500 text-xs font-medium uppercase tracking-wide">
{group.name}
</div>
)}
{group.nodes.map((n) => (
<ExitNodeSelectorItem
key={`${n.ID}-${n.Name}`}
node={n}
onSelect={() => onSelect(n)}
isSelected={selected.ID === n.ID}
/>
))}
</div>
)
)
) : (
<div className="text-center truncate text-gray-500 p-5">
{filter
? `No exit nodes matching “${filter}`
: "No exit nodes available"}
</div>
)}
</div>
</div>
)
}
function ExitNodeSelectorItem({
node,
isSelected,
onSelect,
}: {
node: ExitNode
isSelected: boolean
onSelect: () => void
}) {
return (
<button
key={node.ID}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
onClick={onSelect}
>
<div>
{node.Location && (
<>
<CountryFlag code={node.Location.CountryCode} />{" "}
</>
)}
<span className="leading-snug">{node.Name}</span>
</div>
{isSelected && <Check />}
</button>
)
}
function CountryFlag({ code }: { code: string }) {
return (
<>{countryFlags[code.toLowerCase()]}</> || (
<span className="font-medium text-gray-500 text-xs">
{code.toUpperCase()}
</span>
)
)
}
const countryFlags: { [countryCode: string]: string } = {
ad: "🇦🇩",
ae: "🇦🇪",
af: "🇦🇫",
ag: "🇦🇬",
ai: "🇦🇮",
al: "🇦🇱",
am: "🇦🇲",
ao: "🇦🇴",
aq: "🇦🇶",
ar: "🇦🇷",
as: "🇦🇸",
at: "🇦🇹",
au: "🇦🇺",
aw: "🇦🇼",
ax: "🇦🇽",
az: "🇦🇿",
ba: "🇧🇦",
bb: "🇧🇧",
bd: "🇧🇩",
be: "🇧🇪",
bf: "🇧🇫",
bg: "🇧🇬",
bh: "🇧🇭",
bi: "🇧🇮",
bj: "🇧🇯",
bl: "🇧🇱",
bm: "🇧🇲",
bn: "🇧🇳",
bo: "🇧🇴",
bq: "🇧🇶",
br: "🇧🇷",
bs: "🇧🇸",
bt: "🇧🇹",
bv: "🇧🇻",
bw: "🇧🇼",
by: "🇧🇾",
bz: "🇧🇿",
ca: "🇨🇦",
cc: "🇨🇨",
cd: "🇨🇩",
cf: "🇨🇫",
cg: "🇨🇬",
ch: "🇨🇭",
ci: "🇨🇮",
ck: "🇨🇰",
cl: "🇨🇱",
cm: "🇨🇲",
cn: "🇨🇳",
co: "🇨🇴",
cr: "🇨🇷",
cu: "🇨🇺",
cv: "🇨🇻",
cw: "🇨🇼",
cx: "🇨🇽",
cy: "🇨🇾",
cz: "🇨🇿",
de: "🇩🇪",
dj: "🇩🇯",
dk: "🇩🇰",
dm: "🇩🇲",
do: "🇩🇴",
dz: "🇩🇿",
ec: "🇪🇨",
ee: "🇪🇪",
eg: "🇪🇬",
eh: "🇪🇭",
er: "🇪🇷",
es: "🇪🇸",
et: "🇪🇹",
eu: "🇪🇺",
fi: "🇫🇮",
fj: "🇫🇯",
fk: "🇫🇰",
fm: "🇫🇲",
fo: "🇫🇴",
fr: "🇫🇷",
ga: "🇬🇦",
gb: "🇬🇧",
gd: "🇬🇩",
ge: "🇬🇪",
gf: "🇬🇫",
gg: "🇬🇬",
gh: "🇬🇭",
gi: "🇬🇮",
gl: "🇬🇱",
gm: "🇬🇲",
gn: "🇬🇳",
gp: "🇬🇵",
gq: "🇬🇶",
gr: "🇬🇷",
gs: "🇬🇸",
gt: "🇬🇹",
gu: "🇬🇺",
gw: "🇬🇼",
gy: "🇬🇾",
hk: "🇭🇰",
hm: "🇭🇲",
hn: "🇭🇳",
hr: "🇭🇷",
ht: "🇭🇹",
hu: "🇭🇺",
id: "🇮🇩",
ie: "🇮🇪",
il: "🇮🇱",
im: "🇮🇲",
in: "🇮🇳",
io: "🇮🇴",
iq: "🇮🇶",
ir: "🇮🇷",
is: "🇮🇸",
it: "🇮🇹",
je: "🇯🇪",
jm: "🇯🇲",
jo: "🇯🇴",
jp: "🇯🇵",
ke: "🇰🇪",
kg: "🇰🇬",
kh: "🇰🇭",
ki: "🇰🇮",
km: "🇰🇲",
kn: "🇰🇳",
kp: "🇰🇵",
kr: "🇰🇷",
kw: "🇰🇼",
ky: "🇰🇾",
kz: "🇰🇿",
la: "🇱🇦",
lb: "🇱🇧",
lc: "🇱🇨",
li: "🇱🇮",
lk: "🇱🇰",
lr: "🇱🇷",
ls: "🇱🇸",
lt: "🇱🇹",
lu: "🇱🇺",
lv: "🇱🇻",
ly: "🇱🇾",
ma: "🇲🇦",
mc: "🇲🇨",
md: "🇲🇩",
me: "🇲🇪",
mf: "🇲🇫",
mg: "🇲🇬",
mh: "🇲🇭",
mk: "🇲🇰",
ml: "🇲🇱",
mm: "🇲🇲",
mn: "🇲🇳",
mo: "🇲🇴",
mp: "🇲🇵",
mq: "🇲🇶",
mr: "🇲🇷",
ms: "🇲🇸",
mt: "🇲🇹",
mu: "🇲🇺",
mv: "🇲🇻",
mw: "🇲🇼",
mx: "🇲🇽",
my: "🇲🇾",
mz: "🇲🇿",
na: "🇳🇦",
nc: "🇳🇨",
ne: "🇳🇪",
nf: "🇳🇫",
ng: "🇳🇬",
ni: "🇳🇮",
nl: "🇳🇱",
no: "🇳🇴",
np: "🇳🇵",
nr: "🇳🇷",
nu: "🇳🇺",
nz: "🇳🇿",
om: "🇴🇲",
pa: "🇵🇦",
pe: "🇵🇪",
pf: "🇵🇫",
pg: "🇵🇬",
ph: "🇵🇭",
pk: "🇵🇰",
pl: "🇵🇱",
pm: "🇵🇲",
pn: "🇵🇳",
pr: "🇵🇷",
ps: "🇵🇸",
pt: "🇵🇹",
pw: "🇵🇼",
py: "🇵🇾",
qa: "🇶🇦",
re: "🇷🇪",
ro: "🇷🇴",
rs: "🇷🇸",
ru: "🇷🇺",
rw: "🇷🇼",
sa: "🇸🇦",
sb: "🇸🇧",
sc: "🇸🇨",
sd: "🇸🇩",
se: "🇸🇪",
sg: "🇸🇬",
sh: "🇸🇭",
si: "🇸🇮",
sj: "🇸🇯",
sk: "🇸🇰",
sl: "🇸🇱",
sm: "🇸🇲",
sn: "🇸🇳",
so: "🇸🇴",
sr: "🇸🇷",
ss: "🇸🇸",
st: "🇸🇹",
sv: "🇸🇻",
sx: "🇸🇽",
sy: "🇸🇾",
sz: "🇸🇿",
tc: "🇹🇨",
td: "🇹🇩",
tf: "🇹🇫",
tg: "🇹🇬",
th: "🇹🇭",
tj: "🇹🇯",
tk: "🇹🇰",
tl: "🇹🇱",
tm: "🇹🇲",
tn: "🇹🇳",
to: "🇹🇴",
tr: "🇹🇷",
tt: "🇹🇹",
tv: "🇹🇻",
tw: "🇹🇼",
tz: "🇹🇿",
ua: "🇺🇦",
ug: "🇺🇬",
um: "🇺🇲",
us: "🇺🇸",
uy: "🇺🇾",
uz: "🇺🇿",
va: "🇻🇦",
vc: "🇻🇨",
ve: "🇻🇪",
vg: "🇻🇬",
vi: "🇻🇮",
vn: "🇻🇳",
vu: "🇻🇺",
wf: "🇼🇫",
ws: "🇼🇸",
xk: "🇽🇰",
ye: "🇾🇪",
yt: "🇾🇹",
za: "🇿🇦",
zm: "🇿🇲",
zw: "🇿🇼",
}

View File

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

View File

@@ -0,0 +1,197 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useEffect, useState } from "react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
import { ReactComponent as User } from "src/assets/icons/user.svg"
import { AuthResponse, AuthType } from "src/hooks/auth"
import { NodeData } from "src/hooks/node-data"
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>
}) {
/**
* canConnectOverTS indicates whether the current viewer
* is able to hit the node's web client that's being served
* at http://${node.IP}:5252. If false, this means that the
* viewer must connect to the correct tailnet before being
* able to sign in.
*/
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
const checkTSConnection = useCallback(() => {
if (auth.viewerIdentity) {
setCanConnectOverTS(true) // already connected over ts
return
}
// Otherwise, test connection to the ts IP.
if (isRunningCheck) {
return // already checking
}
setIsRunningCheck(true)
fetch(`http://${node.IP}:5252/ok`, { mode: "no-cors" })
.then(() => {
setIsRunningCheck(false)
setCanConnectOverTS(true)
})
.catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IP])
/**
* Checking connection for first time on page load.
*
* While not connected, we check again whenever the mouse
* enters the popover component, to pick up on the user
* leaving to turn on Tailscale then returning to the view.
* See `onMouseEnter` on the div below.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => checkTSConnection(), [])
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 onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
<div className="text-black text-sm font-medium leading-tight mb-1">
{!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,
"cursor-not-allowed": !canConnectOverTS,
}
)}
onClick={handleSignInClick}
// TODO: add some helper info when disabled
// due to needing to connect to TS
disabled={!canConnectOverTS}
>
{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 className="my-2" />
<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>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import { VersionInfo } from "src/hooks/self-update"
import { Link } from "wouter"
export function UpdateAvailableNotification({
details,
}: {
details: VersionInfo
}) {
return (
<div className="card">
<h2 className="mb-2">
Update available{" "}
{details.LatestVersion && `(v${details.LatestVersion})`}
</h2>
<p className="text-sm mb-1 mt-1">
{details.LatestVersion
? `Version ${details.LatestVersion}`
: "A new update"}{" "}
is now available. <ChangelogText version={details.LatestVersion} />
</p>
<Link
className="button button-blue mt-3 text-sm inline-block"
to="/update"
>
Update now
</Link>
</div>
)
}
// isStableTrack takes a Tailscale version string
// of form X.Y.Z (or vX.Y.Z) and returns whether
// it is a stable release (even value of Y)
// or unstable (odd value of Y).
// eg. isStableTrack("1.48.0") === true
// eg. isStableTrack("1.49.112") === false
function isStableTrack(ver: string): boolean {
const middle = ver.split(".")[1]
if (middle && Number(middle) % 2 === 0) {
return true
}
return false
}
export function ChangelogText({ version }: { version?: string }) {
if (!version || !isStableTrack(version)) {
return null
}
return (
<>
Check out the{" "}
<a href="https://tailscale.com/changelog/" className="link">
release notes
</a>{" "}
to find out what's new!
</>
)
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"
import { apiFetch } from "src/api"
import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components"
import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/hooks/node-data"
import { useLocation } from "wouter"
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>
{node.ClientVersion &&
!node.ClientVersion.RunningLatest &&
!readonly && (
<UpdateAvailableNotification details={node.ClientVersion} />
)}
<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>
<Control.AdminContainer
className="text-neutral-500 text-sm leading-tight text-center"
node={node}
>
Want even more details? Visit{" "}
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
this devices page
</Control.AdminLink>{" "}
in the admin console.
</Control.AdminContainer>
</div>
</>
)
}

View File

@@ -0,0 +1,127 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import { Link } from "wouter"
export default function HomeView({
readonly,
node,
nodeUpdaters,
}: {
readonly: boolean
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
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}
nodeUpdaters={nodeUpdaters}
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
}
/>
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <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

@@ -0,0 +1,153 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React, { useCallback, useState } from "react"
import { apiFetch } from "src/api"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import { NodeData } from "src/hooks/node-data"
import Collapsible from "src/ui/collapsible"
import Input from "src/ui/input"
/**
* LoginView is rendered when the client is not authenticated
* to a tailnet.
*/
export default function LoginView({
data,
refreshData,
}: {
data: NodeData
refreshData: () => void
}) {
const [controlURL, setControlURL] = useState<string>("")
const [authKey, setAuthKey] = useState<string>("")
const login = useCallback(
(opt: TailscaleUpOptions) => {
tailscaleUp(opt).then(refreshData)
},
[refreshData]
)
return (
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<TailscaleIcon className="my-2 mb-8" />
{data.Status === "Stopped" ? (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Connect</h3>
<p className="text-gray-700">
Your device is disconnected from Tailscale.
</p>
</div>
<button
onClick={() => login({})}
className="button button-blue w-full mb-4"
>
Connect to Tailscale
</button>
</>
) : data.IP ? (
<>
<div className="mb-6">
<p className="text-gray-700">
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"
className="link"
target="_blank"
rel="noreferrer"
>
learn more
</a>
.
</p>
</div>
<button
onClick={() => login({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Reauthenticate
</button>
</>
) : (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
<p className="text-gray-700">
Get started by logging in to your Tailscale network.
Or,&nbsp;learn&nbsp;more at{" "}
<a
href="https://tailscale.com/"
className="link"
target="_blank"
rel="noreferrer"
>
tailscale.com
</a>
.
</p>
</div>
<button
onClick={() =>
login({
Reauthenticate: true,
ControlURL: controlURL,
AuthKey: authKey,
})
}
className="button button-blue w-full mb-4"
>
Log In
</button>
<Collapsible trigger="Advanced options">
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
<p className="text-sm text-gray-500">
Connect with a pre-authenticated key.{" "}
<a
href="https://tailscale.com/kb/1085/auth-keys/"
className="link"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<Input
className="mt-2"
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder="tskey-auth-XXX"
/>
<h4 className="font-medium mt-3 mb-1">Server URL</h4>
<p className="text-sm text-gray-500">Base URL of control server.</p>
<Input
className="mt-2"
value={controlURL}
onChange={(e) => setControlURL(e.target.value)}
placeholder="https://login.tailscale.com/"
/>
</Collapsible>
</>
)}
</div>
)
}
type TailscaleUpOptions = {
Reauthenticate?: boolean // force reauthentication
ControlURL?: string
AuthKey?: string
}
function tailscaleUp(options: TailscaleUpOptions) {
return apiFetch("/up", "POST", options)
.then((r) => r.json())
.then((d) => {
d.url && window.open(d.url, "_blank")
})
.catch((e) => {
console.error("Failed to login:", e)
})
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import * as Control from "src/components/control-components"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import Toggle from "src/ui/toggle"
export default function SSHView({
readonly,
node,
nodeUpdaters,
}: {
readonly: boolean
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
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"
rel="noreferrer"
>
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={node.RunningSSHServer}
onChange={() =>
nodeUpdaters.patchPrefs({
RunSSHSet: true,
RunSSH: !node.RunningSSHServer,
})
}
disabled={readonly}
/>
<div className="text-black text-sm font-medium leading-tight">
Run Tailscale SSH server
</div>
</div>
<Control.AdminContainer
className="text-neutral-500 text-sm leading-tight"
node={node}
>
Remember to make sure that the{" "}
<Control.AdminLink node={node} path="/acls">
tailnet policy file
</Control.AdminLink>{" "}
allows other devices to SSH into this device.
</Control.AdminContainer>
</>
)
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React, { useMemo, useState } from "react"
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
import * as Control from "src/components/control-components"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import Button from "src/ui/button"
import Input from "src/ui/input"
export default function SubnetRouterView({
readonly,
node,
nodeUpdaters,
}: {
readonly: boolean
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
const advertisedRoutes = useMemo(
() => node.AdvertisedRoutes || [],
[node.AdvertisedRoutes]
)
const [inputOpen, setInputOpen] = useState<boolean>(
advertisedRoutes.length === 0 && !readonly
)
const [inputText, setInputText] = useState<string>("")
return (
<>
<h1 className="mb-1">Subnet router</h1>
<p className="description mb-5">
Add devices to your tailnet without installing Tailscale.{" "}
<a
href="https://tailscale.com/kb/1019/subnets/"
className="text-indigo-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
{inputOpen ? (
<div className="-mx-5 card shadow">
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
<Input
type="text"
className="text-sm"
placeholder="192.168.0.0/24"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<p className="my-2 h-6 text-neutral-500 text-sm leading-tight">
Add multiple routes by providing a comma-separated list.
</p>
<Button
onClick={() =>
nodeUpdaters
.postSubnetRoutes([
...advertisedRoutes.map((r) => r.Route),
...inputText.split(","),
])
.then(() => {
setInputText("")
setInputOpen(false)
})
}
disabled={readonly || !inputText}
>
Advertise routes
</Button>
</div>
) : (
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
<Plus />
Advertise new route
</Button>
)}
<div className="-mx-5 mt-10">
{advertisedRoutes.length > 0 ? (
<>
<div className="px-5 py-3 bg-white rounded-lg border border-gray-200">
{advertisedRoutes.map((r) => (
<div
className="flex justify-between items-center pb-2.5 mb-2.5 border-b border-b-gray-200 last:pb-0 last:mb-0 last:border-b-0"
key={r.Route}
>
<div className="text-neutral-800 leading-snug">{r.Route}</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
{r.Approved ? (
<CheckCircle className="w-4 h-4" />
) : (
<Clock className="w-4 h-4" />
)}
{r.Approved ? (
<div className="text-emerald-800 text-sm leading-tight">
Approved
</div>
) : (
<div className="text-neutral-500 text-sm leading-tight">
Pending approval
</div>
)}
</div>
<Button
intent="secondary"
className="text-sm font-medium"
onClick={() =>
nodeUpdaters.postSubnetRoutes(
advertisedRoutes
.map((it) => it.Route)
.filter((it) => it !== r.Route)
)
}
disabled={readonly}
>
Stop advertising
</Button>
</div>
</div>
))}
</div>
<Control.AdminContainer
className="mt-3 w-full text-center text-neutral-500 text-sm leading-tight"
node={node}
>
To approve routes, in the admin console go to{" "}
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
the machines route settings
</Control.AdminLink>
.
</Control.AdminContainer>
</>
) : (
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-neutral-500">
Not advertising any routes
</div>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
import { ChangelogText } from "src/components/update-available"
import {
UpdateState,
useInstallUpdate,
VersionInfo,
} from "src/hooks/self-update"
import Spinner from "src/ui/spinner"
import { Link } from "wouter"
/**
* UpdatingView is rendered when the user initiates a Tailscale update, and
* the update is in-progress, failed, or completed.
*/
export function UpdatingView({
versionInfo,
currentVersion,
}: {
versionInfo?: VersionInfo
currentVersion: string
}) {
const { updateState, updateLog } = useInstallUpdate(
currentVersion,
versionInfo
)
return (
<>
<div className="flex-1 flex flex-col justify-center items-center text-center mt-56">
{updateState === UpdateState.InProgress ? (
<>
<Spinner size="sm" className="text-gray-400" />
<h1 className="text-2xl m-3">Update in progress</h1>
<p className="text-gray-400">
The update shouldn't take more than a couple of minutes. Once it's
completed, you will be asked to log in again.
</p>
</>
) : updateState === UpdateState.Complete ? (
<>
<CheckCircleIcon />
<h1 className="text-2xl m-3">Update complete!</h1>
<p className="text-gray-400">
You updated Tailscale
{versionInfo && versionInfo.LatestVersion
? ` to ${versionInfo.LatestVersion}`
: null}
. <ChangelogText version={versionInfo?.LatestVersion} />
</p>
<Link className="button button-blue text-sm m-3" to="/">
Log in to access
</Link>
</>
) : updateState === UpdateState.UpToDate ? (
<>
<CheckCircleIcon />
<h1 className="text-2xl m-3">Up to date!</h1>
<p className="text-gray-400">
You are already running Tailscale {currentVersion}, which is the
newest version available.
</p>
<Link className="button button-blue text-sm m-3" to="/">
Return
</Link>
</>
) : (
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
<>
<XCircleIcon />
<h1 className="text-2xl m-3">Update failed</h1>
<p className="text-gray-400">
Update
{versionInfo && versionInfo.LatestVersion
? ` to ${versionInfo.LatestVersion}`
: null}{" "}
failed.
</p>
<Link className="button button-blue text-sm m-3" to="/">
Return
</Link>
</>
)}
<pre className="h-64 overflow-scroll m-3">
<code>{updateLog}</code>
</pre>
</div>
</>
)
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api"
export enum AuthType {
synology = "synology",
tailscale = "tailscale",
}
export type AuthResponse = {
authNeeded?: AuthType
canManageNode: boolean
viewerIdentity?: {
loginName: string
nodeName: string
nodeIP: string
profilePicUrl?: string
}
}
// useAuth reports and refreshes Tailscale auth status
// for the web client.
export default function useAuth() {
const [data, setData] = useState<AuthResponse>()
const [loading, setLoading] = useState<boolean>(true)
const loadAuth = useCallback(() => {
setLoading(true)
return apiFetch("/auth", "GET")
.then((r) => r.json())
.then((d) => {
setData(d)
switch ((d as AuthResponse).authNeeded) {
case AuthType.synology:
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setLoading(false)
})
break
default:
setLoading(false)
}
return d
})
.catch((error) => {
setLoading(false)
console.error(error)
})
}, [])
const newSession = useCallback(() => {
return apiFetch("/auth/session/new", "GET")
.then((r) => r.json())
.then((d) => {
if (d.authUrl) {
window.open(d.authUrl, "_blank")
return apiFetch("/auth/session/wait", "GET")
}
})
.then(() => loadAuth())
.catch((error) => {
console.error(error)
})
}, [loadAuth])
useEffect(() => {
loadAuth().then((d) => {
if (
!d.canManageNode &&
new URLSearchParams(window.location.search).get("check") === "now"
) {
newSession()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return {
data,
loading,
newSession,
}
}

View File

@@ -0,0 +1,204 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useEffect, useMemo, useState } from "react"
import { apiFetch } from "src/api"
export type ExitNode = {
ID: string
Name: string
Location?: ExitNodeLocation
Online?: boolean
}
type ExitNodeLocation = {
Country: string
CountryCode: CountryCode
City: string
CityCode: CityCode
Priority: number
}
type CountryCode = string
type CityCode = string
export type ExitNodeGroup = {
id: string
name?: string
nodes: ExitNode[]
}
export default function useExitNodes(tailnetName: string, filter?: string) {
const [data, setData] = useState<ExitNode[]>([])
useEffect(() => {
apiFetch("/exit-nodes", "GET")
.then((r) => r.json())
.then((r) => setData(r))
.catch((err) => {
alert("Failed operation: " + err.message)
})
}, [])
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
// First going through exit nodes and splitting them into two groups:
// 1. tailnetNodes: exit nodes advertised by tailnet's own nodes
// 2. locationNodes: exit nodes advertised by non-tailnet Mullvad nodes
let tailnetNodes: ExitNode[] = []
const locationNodes = new Map<CountryCode, Map<CityCode, ExitNode[]>>()
data?.forEach((n) => {
const loc = n.Location
if (!loc) {
// 2023-11-15: Currently, if the node doesn't have
// location information, it is owned by the tailnet.
// Only Mullvad exit nodes have locations filled.
tailnetNodes.push({
...n,
Name: trimDNSSuffix(n.Name, tailnetName),
})
return
}
const countryNodes =
locationNodes.get(loc.CountryCode) || new Map<CityCode, ExitNode[]>()
const cityNodes = countryNodes.get(loc.CityCode) || []
countryNodes.set(loc.CityCode, [...cityNodes, n])
locationNodes.set(loc.CountryCode, countryNodes)
})
return {
tailnetNodesSorted: tailnetNodes.sort(compareByName),
locationNodesMap: locationNodes,
}
}, [data, tailnetName])
const hasFilter = Boolean(filter)
const mullvadNodesSorted = useMemo(() => {
const nodes: ExitNode[] = []
// addBestMatchNode adds the node with the "higest priority"
// match from a list of exit node `options` to `nodes`.
const addBestMatchNode = (
options: ExitNode[],
name: (l: ExitNodeLocation) => string
) => {
const bestNode = highestPriorityNode(options)
if (!bestNode || !bestNode.Location) {
return // not possible, doing this for type safety
}
nodes.push({
...bestNode,
Name: name(bestNode.Location),
})
}
if (!hasFilter) {
// When nothing is searched, only show a single best-matching
// exit node per-country.
//
// There's too many location-based nodes to display all of them.
locationNodesMap.forEach(
// add one node per country
(countryNodes) =>
addBestMatchNode(flattenMap(countryNodes), (l) => l.Country)
)
} else {
// Otherwise, show the best match on a city-level,
// with a "Country: Best Match" node at top.
//
// i.e. We allow for discovering cities through searching.
locationNodesMap.forEach((countryNodes) => {
countryNodes.forEach(
// add one node per city
(cityNodes) =>
addBestMatchNode(cityNodes, (l) => `${l.Country}: ${l.City}`)
)
// add the "Country: Best Match" node
addBestMatchNode(
flattenMap(countryNodes),
(l) => `${l.Country}: Best Match`
)
})
}
return nodes.sort(compareByName)
}, [hasFilter, locationNodesMap])
// Ordered and filtered grouping of exit nodes.
const exitNodeGroups = useMemo(() => {
const filterLower = !filter ? undefined : filter.toLowerCase()
return [
{ id: "self", nodes: filter ? [] : [noExitNode, runAsExitNode] },
{
id: "tailnet",
nodes: filterLower
? tailnetNodesSorted.filter((n) =>
n.Name.toLowerCase().includes(filterLower)
)
: tailnetNodesSorted,
},
{
id: "mullvad",
name: "Mullvad VPN",
nodes: filterLower
? mullvadNodesSorted.filter((n) =>
n.Name.toLowerCase().includes(filterLower)
)
: mullvadNodesSorted,
},
]
}, [tailnetNodesSorted, mullvadNodesSorted, filter])
return { data: exitNodeGroups }
}
// highestPriorityNode finds the highest priority node for use
// (the "best match" node) from a list of exit nodes.
// Nodes with equal priorities are picked between arbitrarily.
function highestPriorityNode(nodes: ExitNode[]): ExitNode | undefined {
return nodes.length === 0
? undefined
: nodes.sort(
(a, b) => (b.Location?.Priority || 0) - (a.Location?.Priority || 0)
)[0]
}
// compareName compares two exit nodes alphabetically by name.
function compareByName(a: ExitNode, b: ExitNode): number {
if (a.Location && b.Location && a.Location.Country === b.Location.Country) {
// Always put "<Country>: Best Match" node at top of country list.
if (a.Name.includes(": Best Match")) {
return -1
} else if (b.Name.includes(": Best Match")) {
return 1
}
}
return a.Name.localeCompare(b.Name)
}
function flattenMap<T, V>(m: Map<T, V[]>): V[] {
return Array.from(m.values()).reduce((prev, curr) => [...prev, ...curr])
}
// trimDNSSuffix trims the tailnet dns name from s, leaving no
// trailing dots.
//
// trimDNSSuffix("hello.ts.net", "ts.net") = "hello"
// trimDNSSuffix("hello", "ts.net") = "hello"
export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
if (s.endsWith(".")) {
s = s.slice(0, -1)
}
if (s.endsWith("." + tailnetDNSName)) {
s = s.replace("." + tailnetDNSName, "")
}
return s
}
export const noExitNode: ExitNode = { ID: "NONE", Name: "None" }
export const runAsExitNode: ExitNode = {
ID: "RUNNING",
Name: "Run as exit node…",
}

View File

@@ -1,35 +1,94 @@
import { useCallback, useEffect, useState } from "react"
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useMemo, useState } from "react"
import { apiFetch, setUnraidCsrfToken } from "src/api"
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update"
export type NodeData = {
Profile: UserProfile
Status: string
Status: NodeState
DeviceName: string
OS: string
IP: string
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
IPv6: string
ID: string
KeyExpiry: string
KeyExpired: boolean
UsingExitNode?: ExitNode
AdvertisingExitNode: boolean
AdvertisedRoutes?: SubnetRoute[]
TUNMode: boolean
IsSynology: boolean
DSMVersion: number
IsUnraid: boolean
UnraidToken: string
IPNVersion: string
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
ClientVersion?: VersionInfo
URLPrefix: string
DomainName: string
TailnetName: string
IsTagged: boolean
Tags: string[]
RunningSSHServer: boolean
ControlAdminURL: string
LicensesURL: string
}
type NodeState =
| "NoState"
| "NeedsLogin"
| "NeedsMachineAuth"
| "Stopped"
| "Starting"
| "Running"
export type UserProfile = {
LoginName: string
DisplayName: string
ProfilePicURL: string
}
export type NodeUpdate = {
AdvertiseRoutes?: string
export type SubnetRoute = {
Route: string
Approved: boolean
}
/**
* NodeUpdaters provides a set of mutation functions for a node.
*
* These functions handle both making the requested change, as well as
* refreshing the app's node data state upon completion to reflect any
* relevant changes in the UI.
*/
export type NodeUpdaters = {
/**
* patchPrefs updates node preferences.
* Only provided preferences will be updated.
* Similar to running the tailscale set command in the CLI.
*/
patchPrefs: (d: PrefsPATCHData) => Promise<void>
/**
* postExitNode updates the node's status as either using or
* running as an exit node.
*/
postExitNode: (d: ExitNode) => Promise<void>
/**
* postSubnetRoutes updates the node's advertised subnet routes.
*/
postSubnetRoutes: (d: string[]) => Promise<void>
}
type PrefsPATCHData = {
RunSSHSet?: boolean
RunSSH?: boolean
}
type RoutesPOSTData = {
UseExitNode?: string
AdvertiseExitNode?: boolean
Reauthenticate?: boolean
ForceLogout?: boolean
AdvertiseRoutes?: string[]
}
// useNodeData returns basic data about the current node.
@@ -49,49 +108,54 @@ export default function useNodeData() {
[setData]
)
const updateNode = useCallback(
(update: NodeUpdate) => {
// The contents of this function are mostly copied over
// from the legacy client's web.html file.
// It makes all data updates through one API endpoint.
// As we build out the web client in React,
// this endpoint will eventually be deprecated.
if (isPosting || !data) {
return
}
const prefsPATCH = useCallback(
(d: PrefsPATCHData) => {
setIsPosting(true)
update = {
...update,
// Default to current data value for any unset fields.
AdvertiseRoutes:
update.AdvertiseRoutes !== undefined
? update.AdvertiseRoutes
: data.AdvertiseRoutes,
AdvertiseExitNode:
update.AdvertiseExitNode !== undefined
? update.AdvertiseExitNode
: data.AdvertiseExitNode,
if (data) {
const optimisticUpdates = data
if (d.RunSSHSet) {
optimisticUpdates.RunningSSHServer = Boolean(d.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)
}
apiFetch("/data", "POST", update, { up: "true" })
.then((r) => r.json())
.then((r) => {
setIsPosting(false)
const err = r["error"]
if (err) {
throw new Error(err)
}
const url = r["url"]
if (url) {
window.open(url, "_blank")
}
refreshData()
const onComplete = () => {
setIsPosting(false)
refreshData() // refresh data after PATCH finishes
}
return apiFetch("/local/v0/prefs", "PATCH", d)
.then(onComplete)
.catch((err) => {
onComplete()
alert("Failed to update prefs")
throw err
})
.catch((err) => alert("Failed operation: " + err.message))
},
[data]
[setIsPosting, refreshData, setData, data]
)
const routesPOST = useCallback(
(d: RoutesPOSTData) => {
setIsPosting(true)
const onComplete = () => {
setIsPosting(false)
refreshData() // refresh data after POST finishes
}
return apiFetch("/routes", "POST", d)
.then(onComplete)
.catch((err) => {
onComplete()
alert("Failed to update routes")
throw err
})
},
[setIsPosting, refreshData]
)
useEffect(
@@ -110,8 +174,36 @@ export default function useNodeData() {
}
},
// Run once.
[]
[refreshData]
)
return { data, refreshData, updateNode, isPosting }
const nodeUpdaters: NodeUpdaters = useMemo(
() => ({
patchPrefs: prefsPATCH,
postExitNode: (node) =>
routesPOST({
AdvertiseExitNode: node.ID === runAsExitNode.ID,
UseExitNode:
node.ID === noExitNode.ID || node.ID === runAsExitNode.ID
? undefined
: node.ID,
AdvertiseRoutes: data?.AdvertisedRoutes?.map((r) => r.Route), // unchanged
}),
postSubnetRoutes: (routes) =>
routesPOST({
AdvertiseRoutes: routes,
AdvertiseExitNode: data?.AdvertisingExitNode, // unchanged
UseExitNode: data?.UsingExitNode?.ID, // unchanged
}),
}),
[
data?.AdvertisingExitNode,
data?.AdvertisedRoutes,
data?.UsingExitNode?.ID,
prefsPATCH,
routesPOST,
]
)
return { data, refreshData, nodeUpdaters, isPosting }
}

View File

@@ -0,0 +1,134 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { apiFetch } from "src/api"
// this type is deserialized from tailcfg.ClientVersion,
// so it should not include fields not included in that type.
export type VersionInfo = {
RunningLatest: boolean
LatestVersion?: string
}
// see ipnstate.UpdateProgress
export type UpdateProgress = {
status: "UpdateFinished" | "UpdateInProgress" | "UpdateFailed"
message: string
version: string
}
export enum UpdateState {
UpToDate,
Available,
InProgress,
Complete,
Failed,
}
// useInstallUpdate initiates and tracks a Tailscale self-update via the LocalAPI,
// and returns state messages showing the progress of the update.
export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
const [updateState, setUpdateState] = useState<UpdateState>(
cv?.RunningLatest ? UpdateState.UpToDate : UpdateState.Available
)
const [updateLog, setUpdateLog] = useState<string>("")
const appendUpdateLog = useCallback(
(msg: string) => {
setUpdateLog(updateLog + msg + "\n")
},
[updateLog, setUpdateLog]
)
useEffect(() => {
if (updateState !== UpdateState.Available) {
// useEffect cleanup function
return () => {}
}
setUpdateState(UpdateState.InProgress)
apiFetch("/local/v0/update/install", "POST").catch((err) => {
console.error(err)
setUpdateState(UpdateState.Failed)
})
let tsAwayForPolls = 0
let updateMessagesRead = 0
let timer = 0
function poll() {
apiFetch("/local/v0/update/progress", "GET")
.then((res) => res.json())
.then((res: UpdateProgress[]) => {
// res contains a list of UpdateProgresses that is strictly increasing
// in size, so updateMessagesRead keeps track (across calls of poll())
// of how many of those we have already read. This is why it is not
// initialized to zero here and we don't just use res.forEach()
for (; updateMessagesRead < res.length; ++updateMessagesRead) {
const up = res[updateMessagesRead]
if (up.status === "UpdateFailed") {
setUpdateState(UpdateState.Failed)
if (up.message) appendUpdateLog("ERROR: " + up.message)
return
}
if (up.status === "UpdateFinished") {
// if update finished and tailscaled did not go away (ie. did not restart),
// then the version being the same might not be an error, it might just require
// the user to restart Tailscale manually (this is required in some cases in the
// clientupdate package).
if (up.version === currentVersion && tsAwayForPolls > 0) {
setUpdateState(UpdateState.Failed)
appendUpdateLog(
"ERROR: Update failed, still running Tailscale " + up.version
)
if (up.message) appendUpdateLog("ERROR: " + up.message)
} else {
setUpdateState(UpdateState.Complete)
if (up.message) appendUpdateLog("INFO: " + up.message)
}
return
}
setUpdateState(UpdateState.InProgress)
if (up.message) appendUpdateLog("INFO: " + up.message)
}
// If we have gone through the entire loop without returning out of the function,
// the update is still in progress. So we want to poll again for further status
// updates.
timer = setTimeout(poll, 1000)
})
.catch((err) => {
++tsAwayForPolls
if (tsAwayForPolls >= 5 * 60) {
setUpdateState(UpdateState.Failed)
appendUpdateLog(
"ERROR: tailscaled went away but did not come back!"
)
appendUpdateLog("ERROR: last error received:")
appendUpdateLog(err.toString())
} else {
timer = setTimeout(poll, 1000)
}
})
}
poll()
// useEffect cleanup function
return () => {
if (timer) clearTimeout(timer)
timer = 0
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return !cv
? { updateState: UpdateState.UpToDate, updateLog: "" }
: { updateState, updateLog }
}

View File

@@ -2,6 +2,192 @@
@tailwind components;
@tailwind utilities;
@layer base {
@font-face {
font-family: "Inter";
font-weight: 100 900;
font-style: normal;
font-display: swap;
src: url("./assets/fonts/Inter.var.latin.woff2") format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
U+02BB-02BC, U+2000-206F, U+2122, U+2190-2199, U+2212, U+2215, U+FEFF,
U+FFFD, U+E06B-E080, U+02E2, U+02E2, U+02B0, U+1D34, U+1D57, U+1D40,
U+207F, U+1D3A, U+1D48, U+1D30, U+02B3, U+1D3F;
}
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];
}
/**
* .input defines default text input field styling. These styles should
* correspond to .button, sharing a similar height and rounding, since .input
* and .button are commonly used together.
*/
.input,
.input-wrapper {
@apply appearance-none leading-tight rounded-md bg-white border border-gray-300 hover:border-gray-400 transition-colors w-full h-input;
}
.input {
@apply px-3;
}
.input::placeholder,
.input-wrapper::placeholder {
@apply text-gray-400;
}
.input:disabled,
.input-wrapper:disabled {
@apply border-gray-300;
@apply bg-gray-0;
@apply cursor-not-allowed;
}
.input:focus,
.input-wrapper:focus-within {
@apply outline-none ring border-gray-400;
}
.input-error {
@apply border-red-200;
}
}
@layer utilities {
.h-input {
@apply h-[2.375rem];
}
}
/**
* Non-Tailwind styles begin here.
*/
@@ -128,3 +314,23 @@ html {
background-color: #b22d30;
border-color: #b22d30;
}
/**
* .spinner creates a circular animated spinner, most often used to indicate a
* loading state. The .spinner element must define a width, height, and
* border-width for the spinner to apply.
*/
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.spinner {
@apply border-transparent border-t-current border-l-current rounded-full;
animation: spin 700ms linear infinite;
}

View File

@@ -1,3 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Preserved js license comment for web client app.
/**
* @license
* Copyright (c) Tailscale Inc & AUTHORS
* SPDX-License-Identifier: BSD-3-Clause
*/
import React from "react"
import { createRoot } from "react-dom/client"
import App from "src/components/app"

View File

@@ -0,0 +1,51 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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,33 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { ButtonHTMLAttributes } from "react"
type Props = {
intent?: "primary" | "secondary"
} & ButtonHTMLAttributes<HTMLButtonElement>
export default function Button(props: Props) {
const { intent = "primary", className, disabled, children, ...rest } = props
return (
<button
className={cx(
"px-3 py-2 rounded shadow justify-center items-center gap-2.5 inline-flex font-medium",
{
"bg-indigo-500 text-white": intent === "primary" && !disabled,
"bg-indigo-400 text-indigo-200": intent === "primary" && disabled,
"bg-stone-50 shadow border border-stone-200 text-neutral-800":
intent === "secondary",
"cursor-not-allowed": disabled,
},
className
)}
{...rest}
disabled={disabled}
>
{children}
</button>
)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import * as Primitive from "@radix-ui/react-collapsible"
import React, { useState } from "react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
type CollapsibleProps = {
trigger?: string
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
export default function Collapsible(props: CollapsibleProps) {
const { children, trigger, onOpenChange } = props
const [open, setOpen] = useState(props.open)
return (
<Primitive.Root
open={open}
onOpenChange={(open) => {
setOpen(open)
onOpenChange?.(open)
}}
>
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-stone-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
</span>
{trigger}
</Primitive.Trigger>
<Primitive.Content className="mt-2">{children}</Primitive.Content>
</Primitive.Root>
)
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { InputHTMLAttributes } from "react"
type Props = {
className?: string
inputClassName?: string
error?: boolean
suffix?: JSX.Element
} & InputHTMLAttributes<HTMLInputElement>
// Input is styled in a way that only works for text inputs.
const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
const {
className,
inputClassName,
error,
prefix,
suffix,
disabled,
...rest
} = props
return (
<div className={cx("relative", className)}>
<input
ref={ref}
className={cx("input z-10", inputClassName, {
"input-error": error,
})}
disabled={disabled}
{...rest}
/>
{suffix ? (
<div className="bg-white top-1 bottom-1 right-1 rounded-r-md absolute flex items-center">
{suffix}
</div>
) : null}
</div>
)
})
export default Input

View File

@@ -0,0 +1,109 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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

@@ -0,0 +1,41 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"
export default function ProfilePic({
url,
size = "large",
className,
}: {
url?: string
size?: "small" | "medium" | "large"
className?: string
}) {
return (
<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-full h-full flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${url})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-full h-full flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
)
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { forwardRef, InputHTMLAttributes } from "react"
import { ReactComponent as Search } from "src/assets/icons/search.svg"
type Props = {
className?: string
inputClassName?: string
} & InputHTMLAttributes<HTMLInputElement>
/**
* SearchInput is a standard input with a search icon.
*/
const SearchInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
const { className, inputClassName, ...rest } = props
return (
<div className={cx("relative", className)}>
<Search className="absolute w-[1.25em] h-full ml-2" />
<input
type="text"
className={cx("input px-8", inputClassName)}
ref={ref}
{...rest}
/>
</div>
)
})
SearchInput.displayName = "SearchInput"
export default SearchInput

View File

@@ -0,0 +1,32 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { HTMLAttributes } from "react"
type Props = {
className?: string
size: "sm" | "md"
} & HTMLAttributes<HTMLDivElement>
export default function Spinner(props: Props) {
const { className, size, ...rest } = props
return (
<div
className={cx(
"spinner inline-block rounded-full align-middle",
{
"border-2 w-4 h-4": size === "sm",
"border-4 w-8 h-8": size === "md",
},
className
)}
{...rest}
/>
)
}
Spinner.defaultProps = {
size: "md",
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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

@@ -7,6 +7,7 @@
package web
import (
"errors"
"fmt"
"net/http"
"os/exec"
@@ -17,62 +18,42 @@ import (
// authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client.
// It reports true if the request is authorized to continue, and false otherwise.
// authorizeSynology manages writing out any relevant authorization errors to the
// ResponseWriter itself.
func authorizeSynology(w http.ResponseWriter, r *http.Request) (ok bool) {
if synoTokenRedirect(w, r) {
return false
// If the user is authenticated, but not authorized to use the client, an error is returned.
func authorizeSynology(r *http.Request) (authorized bool, err error) {
if !hasSynoToken(r) {
return false, nil
}
// authenticate the Synology user
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
http.Error(w, fmt.Sprintf("auth: %v: %s", err, out), http.StatusUnauthorized)
return false
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 {
http.Error(w, err.Error(), http.StatusForbidden)
return false
return false, err
}
if !isAdmin {
http.Error(w, "not a member of administrators group", http.StatusForbidden)
return false
return false, errors.New("not a member of administrators group")
}
return true
return true, nil
}
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
func hasSynoToken(r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" {
return false
return true
}
if r.URL.Query().Get("SynoToken") != "" {
return false
return true
}
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
return false
return true
}
// We need a SynoToken for authenticate.cgi.
// So we tell the client to get one.
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
return true
return false
}
const synoTokenRedirectHTML = `<html>
Redirecting with session token...
<script>
fetch("/webman/login.cgi")
.then(r => r.json())
.then(data => {
u = new URL(window.location)
u.searchParams.set("SynoToken", data.SynoToken)
document.location = u
})
</script>
`

View File

@@ -1,12 +1,46 @@
const plugin = require("tailwindcss/plugin")
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
fontFamily: {
sans: [
"Inter",
"-apple-system",
"BlinkMacSystemFont",
"Helvetica",
"Arial",
"sans-serif",
],
mono: [
"SFMono-Regular",
"SFMono Regular",
"Consolas",
"Liberation Mono",
"Menlo",
"Courier",
"monospace",
],
},
fontWeight: {
normal: "400",
medium: "500",
semibold: "600",
bold: "700",
},
extend: {},
},
plugins: [],
plugins: [
plugin(function ({ addVariant }) {
addVariant("state-open", [
'&[data-state="open"]',
'[data-state="open"] &',
])
addVariant("state-closed", [
'&[data-state="closed"]',
'[data-state="closed"] &',
])
}),
],
}

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/**"],

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,14 @@
package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
@@ -18,10 +20,12 @@ import (
"github.com/google/go-cmp/cmp"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/memnet"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
)
func TestQnapAuthnURL(t *testing.T) {
@@ -124,7 +128,7 @@ func TestServeAPI(t *testing.T) {
res := w.Result()
defer res.Body.Close()
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
t.Errorf("wrong status; want=%q, got=%q", tt.wantStatus, gotStatus)
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
}
body, err := io.ReadAll(res.Body)
if err != nil {
@@ -150,76 +154,50 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
tags := views.SliceOf([]string{"tag:server"})
tailnetNodes := map[string]*apitype.WhoIsResponse{
userANodeIP: {
Node: &tailcfg.Node{StableID: "Node1"},
Node: &tailcfg.Node{ID: 1, StableID: "1"},
UserProfile: userA,
},
userBNodeIP: {
Node: &tailcfg.Node{StableID: "Node2"},
Node: &tailcfg.Node{ID: 2, StableID: "2"},
UserProfile: userB,
},
taggedNodeIP: {
Node: &tailcfg.Node{StableID: "Node3", Tags: tags.AsSlice()},
Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()},
},
}
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
// Serve a testing localapi handler so we can simulate
// whois responses without a functioning tailnet.
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/localapi/v0/whois":
addr := r.URL.Query().Get("addr")
if addr == "" {
t.Fatalf("/whois call missing \"addr\" query")
}
if node := tailnetNodes[addr]; node != nil {
if err := json.NewEncoder(w).Encode(&node); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
}
http.Error(w, "not a node", http.StatusUnauthorized)
return
case "/localapi/v0/status":
status := ipnstate.Status{Self: selfNode}
if err := json.NewEncoder(w).Encode(status); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
default:
// Only the above two endpoints get triggered from getTailscaleBrowserSession.
// No need to mock any of the other localapi endpoint.
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
}
})}
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil)
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
s := &Server{
timeNow: time.Now,
lc: &tailscale.LocalClient{Dial: lal.Dial},
}
// Add some browser sessions to cache state.
userASession := &browserSession{
ID: "cookie1",
SrcNode: "Node1",
SrcNode: 1,
SrcUser: userA.ID,
Authenticated: time.Time{}, // not yet authenticated
Created: time.Now(),
Authenticated: false, // not yet authenticated
}
userBSession := &browserSession{
ID: "cookie2",
SrcNode: "Node2",
SrcNode: 2,
SrcUser: userB.ID,
Authenticated: time.Now().Add(-2 * sessionCookieExpiry), // expired
Created: time.Now().Add(-2 * sessionCookieExpiry),
Authenticated: true, // expired
}
userASessionAuthorized := &browserSession{
ID: "cookie3",
SrcNode: "Node1",
SrcNode: 1,
SrcUser: userA.ID,
Authenticated: time.Now(), // authenticated and not expired
Created: time.Now(),
Authenticated: true, // authenticated and not expired
}
s.browserSessions.Store(userASession.ID, userASession)
s.browserSessions.Store(userBSession.ID, userBSession)
@@ -265,11 +243,26 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
wantError: errNotOwner,
},
{
name: "tagged-source",
name: "tagged-remote-source",
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
remoteAddr: taggedNodeIP,
wantSession: nil,
wantError: errTaggedSource,
wantError: errTaggedRemoteSource,
},
{
name: "tagged-local-source",
selfNode: &ipnstate.PeerStatus{ID: "3"},
remoteAddr: taggedNodeIP, // same node as selfNode
wantSession: nil,
wantError: errTaggedLocalSource,
},
{
name: "not-tagged-local-source",
selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID},
remoteAddr: userANodeIP, // same node as selfNode
cookie: userASession.ID,
wantSession: userASession,
wantError: nil, // should not error
},
{
name: "has-session",
@@ -312,16 +305,551 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
if tt.cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
}
session, err := s.getTailscaleBrowserSession(r)
session, _, err := s.getSession(r)
if !errors.Is(err, tt.wantError) {
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
}
if diff := cmp.Diff(session, tt.wantSession); diff != "" {
t.Errorf("wrong session; (-got+want):%v", diff)
}
if gotIsAuthorized := session.isAuthorized(); gotIsAuthorized != tt.wantIsAuthorized {
if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized {
t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized)
}
})
}
}
// TestAuthorizeRequest tests the s.authorizeRequest function.
// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
func TestAuthorizeRequest(t *testing.T) {
// Create self and remoteNode owned by same user.
// See TestGetTailscaleBrowserSession for tests of
// browser sessions w/ different users.
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user}
remoteIP := "100.100.100.101"
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
func() *ipnstate.PeerStatus { return self },
nil,
)
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
}
validCookie := "ts-cookie"
s.browserSessions.Store(validCookie, &browserSession{
ID: validCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: time.Now(),
Authenticated: true,
})
tests := []struct {
reqPath string
reqMethod string
wantOkNotOverTailscale bool // simulates req over public internet
wantOkWithoutSession bool // simulates req over TS without valid browser session
wantOkWithSession bool // simulates req over TS with valid browser session
}{{
reqPath: "/api/data",
reqMethod: httpm.GET,
wantOkNotOverTailscale: false,
wantOkWithoutSession: true,
wantOkWithSession: true,
}, {
reqPath: "/api/data",
reqMethod: httpm.POST,
wantOkNotOverTailscale: false,
wantOkWithoutSession: false,
wantOkWithSession: true,
}, {
reqPath: "/api/somethingelse",
reqMethod: httpm.GET,
wantOkNotOverTailscale: false,
wantOkWithoutSession: false,
wantOkWithSession: true,
}, {
reqPath: "/assets/styles.css",
wantOkNotOverTailscale: false,
wantOkWithoutSession: true,
wantOkWithSession: true,
}}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) {
doAuthorize := func(remoteAddr string, cookie string) bool {
r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil)
r.RemoteAddr = remoteAddr
if cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
}
w := httptest.NewRecorder()
return s.authorizeRequest(w, r)
}
// Do request from non-Tailscale IP.
if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale {
t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk)
}
// Do request from Tailscale IP w/o associated session.
if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession {
t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk)
}
// Do request from Tailscale IP w/ associated session.
if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession {
t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk)
}
})
}
}
func TestServeAuth(t *testing.T) {
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")},
}
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,
}
testControlURL := &defaultControlURL
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
func() *ipnstate.PeerStatus { return self },
func() *ipn.Prefs {
return &ipn.Prefs{ControlURL: *testControlURL}
},
)
defer localapi.Close()
go localapi.Serve(lal)
timeNow := time.Now()
oneHourAgo := timeNow.Add(-time.Hour)
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: func() time.Time { return timeNow },
newAuthURL: mockNewAuthURL,
waitAuthURL: mockWaitAuthURL,
}
successCookie := "ts-cookie-success"
s.browserSessions.Store(successCookie, &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
})
failureCookie := "ts-cookie-failure"
s.browserSessions.Store(failureCookie, &browserSession{
ID: failureCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathError,
AuthURL: *testControlURL + testAuthPathError,
})
expiredCookie := "ts-cookie-expired"
s.browserSessions.Store(expiredCookie, &browserSession{
ID: expiredCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: sixtyDaysAgo,
AuthID: "/a/old-auth-url",
AuthURL: *testControlURL + "/a/old-auth-url",
})
tests := []struct {
name string
controlURL string // if empty, defaultControlURL is used
cookie string // cookie attached to request
wantNewCookie bool // want new cookie generated during request
wantSession *browserSession // session associated w/ cookie after request
path string
wantStatus int
wantResp any
}{
{
name: "no-session",
path: "/api/auth",
wantStatus: http.StatusOK,
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
wantNewCookie: false,
wantSession: nil,
},
{
name: "new-session",
path: "/api/auth/session/new",
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID", // gets swapped for newly created ID by test
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: timeNow,
AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath,
Authenticated: false,
},
},
{
name: "query-existing-incomplete-session",
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: false,
},
},
{
name: "existing-session-used",
path: "/api/auth/session/new", // should not create new session
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: false,
},
},
{
name: "transition-to-successful-session",
path: "/api/auth/session/wait",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: nil,
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: true,
},
},
{
name: "query-existing-complete-session",
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: true,
},
},
{
name: "transition-to-failed-session",
path: "/api/auth/session/wait",
cookie: failureCookie,
wantStatus: http.StatusUnauthorized,
wantResp: nil,
wantSession: nil, // session deleted
},
{
name: "failed-session-cleaned-up",
path: "/api/auth/session/new",
cookie: failureCookie,
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID",
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: timeNow,
AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath,
Authenticated: false,
},
},
{
name: "expired-cookie-gets-new-session",
path: "/api/auth/session/new",
cookie: expiredCookie,
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID",
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: timeNow,
AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath,
Authenticated: false,
},
},
{
name: "control-server-no-check-mode",
controlURL: "http://alternate-server.com/",
path: "/api/auth/session/new",
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID", // gets swapped for newly created ID by test
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: timeNow,
Authenticated: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.controlURL != "" {
testControlURL = &tt.controlURL
} else {
testControlURL = &defaultControlURL
}
r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil)
r.RemoteAddr = remoteIP
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
w := httptest.NewRecorder()
s.serve(w, r)
res := w.Result()
defer res.Body.Close()
// Validate response status/data.
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
}
var gotResp string
if res.StatusCode == http.StatusOK {
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
gotResp = strings.Trim(string(body), "\n")
}
var wantResp string
if tt.wantResp != nil {
b, _ := json.Marshal(tt.wantResp)
wantResp = string(b)
}
if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" {
t.Errorf("wrong response; (-got+want):%v", diff)
}
// Validate cookie creation.
sessionID := tt.cookie
var gotCookie bool
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookieName {
gotCookie = true
sessionID = c.Value
break
}
}
if gotCookie != tt.wantNewCookie {
t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
}
// Validate browser session contents.
var gotSesson *browserSession
if s, ok := s.browserSessions.Load(sessionID); ok {
gotSesson = s.(*browserSession)
}
if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" {
// If requested, swap in the generated session ID before
// comparing got/want.
tt.wantSession.ID = sessionID
}
if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" {
t.Errorf("wrong session; (-got+want):%v", diff)
}
})
}
}
func TestRequireTailscaleIP(t *testing.T) {
self := &ipnstate.PeerStatus{
TailscaleIPs: []netip.Addr{
netip.MustParseAddr("100.1.2.3"),
netip.MustParseAddr("fd7a:115c::1234"),
},
}
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil)
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
logf: t.Logf,
}
tests := []struct {
name string
target string
wantHandled bool
wantLocation string
}{
{
name: "localhost",
target: "http://localhost/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv4-no-port",
target: "http://100.1.2.3/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv4-correct-port",
target: "http://100.1.2.3:5252/",
wantHandled: false,
},
{
name: "ipv6-no-port",
target: "http://[fd7a:115c::1234]/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv6-correct-port",
target: "http://[fd7a:115c::1234]:5252/",
wantHandled: false,
},
{
name: "quad-100",
target: "http://100.100.100.100/",
wantHandled: false,
},
{
name: "ipv6-service-addr",
target: "http://[fd7a:115c:a1e0::53]/",
wantHandled: false,
},
}
for _, tt := range tests {
t.Run(tt.target, func(t *testing.T) {
s.logf = t.Logf
r := httptest.NewRequest(httpm.GET, tt.target, nil)
w := httptest.NewRecorder()
handled := s.requireTailscaleIP(w, r)
if handled != tt.wantHandled {
t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled)
}
location := w.Header().Get("Location")
if location != tt.wantLocation {
t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location)
}
})
}
}
var (
defaultControlURL = "https://controlplane.tailscale.com"
testAuthPath = "/a/12345"
testAuthPathSuccess = "/a/will-succeed"
testAuthPathError = "/a/will-error"
)
// mockLocalAPI constructs a test localapi handler that can be used
// to simulate localapi responses without a functioning tailnet.
//
// self accepts a function that resolves to a self node status,
// so that tests may swap out the /localapi/v0/status response
// as desired.
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs) *http.Server {
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/localapi/v0/whois":
addr := r.URL.Query().Get("addr")
if addr == "" {
t.Fatalf("/whois call missing \"addr\" query")
}
if node := whoIs[addr]; node != nil {
writeJSON(w, &node)
return
}
http.Error(w, "not a node", http.StatusUnauthorized)
return
case "/localapi/v0/status":
writeJSON(w, ipnstate.Status{Self: self()})
return
case "/localapi/v0/prefs":
writeJSON(w, prefs())
return
default:
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
}
})}
}
func mockNewAuthURL(_ context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
// Create new dummy auth URL.
return &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: defaultControlURL + testAuthPath}, nil
}
func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
switch id {
case testAuthPathSuccess: // successful auth URL
return &tailcfg.WebClientAuthResponse{Complete: true}, nil
case testAuthPathError: // error auth URL
return nil, errors.New("authenticated as wrong user")
default:
return nil, errors.New("unknown id")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ import (
"github.com/google/uuid"
"tailscale.com/clientupdate/distsign"
"tailscale.com/types/logger"
"tailscale.com/util/cmpver"
"tailscale.com/util/winutil"
"tailscale.com/version"
"tailscale.com/version/distro"
@@ -72,11 +73,12 @@ type Arguments struct {
//
// Leaving this empty is the same as using CurrentTrack.
Version string
// AppStore forces a local app store check, even if the current binary was
// not installed via an app store. TODO(cpalmer): Remove this.
AppStore bool
// Logf is a logger for update progress messages.
Logf logger.Logf
// Stdout and Stderr should be used for output instead of os.Stdout and
// os.Stderr.
Stdout io.Writer
Stderr io.Writer
// Confirm is called when a new version is available and should return true
// if this new version should be installed. When Confirm returns false, the
// update is aborted.
@@ -84,6 +86,10 @@ type Arguments struct {
// PkgsAddr is the address of the pkgs server to fetch updates from.
// Defaults to "https://pkgs.tailscale.com".
PkgsAddr string
// ForAutoUpdate should be true when Updater is created in auto-update
// context. When true, NewUpdater returns an error if it cannot be used for
// auto-updates (even if Updater.Update field is non-nil).
ForAutoUpdate bool
}
func (args Arguments) validate() error {
@@ -108,10 +114,20 @@ func NewUpdater(args Arguments) (*Updater, error) {
up := Updater{
Arguments: args,
}
up.Update = up.getUpdateFunction()
if up.Stdout == nil {
up.Stdout = os.Stdout
}
if up.Stderr == nil {
up.Stderr = os.Stderr
}
var canAutoUpdate bool
up.Update, canAutoUpdate = up.getUpdateFunction()
if up.Update == nil {
return nil, errors.ErrUnsupported
}
if args.ForAutoUpdate && !canAutoUpdate {
return nil, errors.ErrUnsupported
}
switch up.Version {
case StableTrack, UnstableTrack:
up.track = up.Version
@@ -136,52 +152,74 @@ func NewUpdater(args Arguments) (*Updater, error) {
type updateFunction func() error
func (up *Updater) getUpdateFunction() updateFunction {
func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
switch runtime.GOOS {
case "windows":
return up.updateWindows
return up.updateWindows, true
case "linux":
switch distro.Get() {
case distro.Synology:
return up.updateSynology
// Synology updates use our own pkgs.tailscale.com instead of the
// Synology Package Center. We should eventually get to a regular
// release cadence with Synology Package Center and use their
// auto-update mechanism.
return up.updateSynology, false
case distro.Debian: // includes Ubuntu
return up.updateDebLike
return up.updateDebLike, true
case distro.Arch:
return up.updateArchLike
if up.archPackageInstalled() {
// Arch update func just prints a message about how to update,
// it doesn't support auto-updates.
return up.updateArchLike, false
}
return up.updateLinuxBinary, true
case distro.Alpine:
return up.updateAlpineLike
return up.updateAlpineLike, true
case distro.Unraid:
return up.updateUnraid, true
case distro.QNAP:
return up.updateQNAP, true
}
switch {
case haveExecutable("pacman"):
return up.updateArchLike
if up.archPackageInstalled() {
// Arch update func just prints a message about how to update,
// it doesn't support auto-updates.
return up.updateArchLike, false
}
return up.updateLinuxBinary, true
case haveExecutable("apt-get"): // TODO(awly): add support for "apt"
// The distro.Debian switch case above should catch most apt-based
// systems, but add this fallback just in case.
return up.updateDebLike
return up.updateDebLike, true
case haveExecutable("dnf"):
return up.updateFedoraLike("dnf")
return up.updateFedoraLike("dnf"), true
case haveExecutable("yum"):
return up.updateFedoraLike("yum")
return up.updateFedoraLike("yum"), true
case haveExecutable("apk"):
return up.updateAlpineLike
return up.updateAlpineLike, true
}
// If nothing matched, fall back to tarball updates.
if up.Update == nil {
return up.updateLinuxBinary
return up.updateLinuxBinary, true
}
case "darwin":
switch {
case !up.Arguments.AppStore && !version.IsSandboxedMacOS():
return nil
case !up.Arguments.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
return up.updateMacSys
case version.IsMacAppStore():
// App store update func just opens the store page, it doesn't
// support auto-updates.
return up.updateMacAppStore, false
case version.IsMacSysExt():
// Macsys update func kicks off Sparkle. Auto-updates are done by
// Sparkle.
return up.updateMacSys, false
default:
return up.updateMacAppStore
return nil, false
}
case "freebsd":
return up.updateFreeBSD
return up.updateFreeBSD, true
}
return nil
return nil, false
}
// Update runs a single update attempt using the platform-specific mechanism.
@@ -201,8 +239,12 @@ func Update(args Arguments) error {
}
func (up *Updater) confirm(ver string) bool {
if version.Short() == ver {
up.Logf("already running %v; no update needed", ver)
switch cmpver.Compare(version.Short(), ver) {
case 0:
up.Logf("already running %v version %v; no update needed", up.track, ver)
return false
case 1:
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.track, version.Short(), ver)
return false
}
if up.Confirm != nil {
@@ -217,6 +259,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()
@@ -237,10 +282,8 @@ func (up *Updater) updateSynology() error {
if !up.confirm(latest.SPKsVersion) {
return nil
}
if err := requireRoot(); err != nil {
return err
}
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-update*", "*.spk"))
// Download the SPK into a temporary directory.
spkDir, err := os.MkdirTemp("", "tailscale-update")
if err != nil {
@@ -256,9 +299,9 @@ func (up *Updater) updateSynology() error {
// connected over tailscale ssh and this parent process dies. Otherwise, if
// you abort synopkg install mid-way, tailscaled is not restarted.
cmd := exec.Command("nohup", "synopkg", "install", spkPath)
// Don't attach cmd.Stdout to os.Stdout because nohup will redirect that
// into nohup.out file. synopkg doesn't have any progress output anyway, it
// just spits out a JSON result when done.
// Don't attach cmd.Stdout to Stdout because nohup will redirect that into
// nohup.out file. synopkg doesn't have any progress output anyway, it just
// spits out a JSON result when done.
out, err := cmd.CombinedOutput()
if err != nil {
if dsmVersion == 6 && bytes.Contains(out, []byte("error = [290]")) {
@@ -369,17 +412,25 @@ func (up *Updater) updateDebLike() error {
// we're not updating them:
"-o", "APT::Get::List-Cleanup=0",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
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 = os.Stdout
cmd.Stderr = os.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
@@ -442,12 +493,12 @@ func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []
return buf.Bytes(), nil
}
func (up *Updater) archPackageInstalled() bool {
err := exec.Command("pacman", "--query", "tailscale").Run()
return err == nil
}
func (up *Updater) updateArchLike() error {
if err := exec.Command("pacman", "--query", "tailscale").Run(); err != nil && isExitError(err) {
// Tailscale was not installed via pacman, update via tarball download
// instead.
return up.updateLinuxBinary()
}
// Arch maintainer asked us not to implement "tailscale update" or
// auto-updates on Arch-based distros:
// https://github.com/tailscale/tailscale/issues/6995#issuecomment-1687080106
@@ -491,8 +542,8 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error {
}
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return err
}
@@ -562,11 +613,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 {
@@ -577,8 +628,8 @@ func (up *Updater) updateAlpineLike() (err error) {
}
cmd := exec.Command("apk", "upgrade", "tailscale")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using apk: %w", err)
}
@@ -608,76 +659,76 @@ func (up *Updater) updateMacSys() error {
}
func (up *Updater) updateMacAppStore() error {
out, err := exec.Command("defaults", "read", "/Library/Preferences/com.apple.commerce.plist", "AutoUpdate").CombinedOutput()
// We can't trigger the update via App Store from the sandboxed app. At
// most, we can open the App Store page for them.
up.Logf("Please use the App Store to update Tailscale.\nConsider enabling Automatic Updates in the App Store Settings, if you haven't already.\nOpening the Tailscale app page...")
out, err := exec.Command("open", "https://apps.apple.com/us/app/tailscale/id1475387142").CombinedOutput()
if err != nil {
return fmt.Errorf("can't check App Store auto-update setting: %w, output: %q", err, string(out))
}
const on = "1\n"
if string(out) != on {
up.Logf("NOTE: Automatic updating for App Store apps is turned off. You can change this setting in System Settings (search for update).")
}
out, err = exec.Command("softwareupdate", "--list").CombinedOutput()
if err != nil {
return fmt.Errorf("can't check App Store for available updates: %w, output: %q", err, string(out))
}
newTailscale := parseSoftwareupdateList(out)
if newTailscale == "" {
up.Logf("no Tailscale update available")
return nil
}
newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
if !up.confirm(newTailscaleVer) {
return nil
}
cmd := exec.Command("sudo", "softwareupdate", "--install", newTailscale)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("can't install App Store update for Tailscale: %w", err)
return fmt.Errorf("can't open the Tailscale page in App Store: %w, output:\n%s", err, string(out))
}
return nil
}
var macOSAppStoreListPattern = regexp.MustCompile(`(?m)^\s+\*\s+Label:\s*(Tailscale-\d[\d\.]+)`)
// parseSoftwareupdateList searches the output of `softwareupdate --list` on
// Darwin and returns the matching Tailscale package label. If there is none,
// returns the empty string.
//
// See TestParseSoftwareupdateList for example inputs.
func parseSoftwareupdateList(stdout []byte) string {
matches := macOSAppStoreListPattern.FindSubmatch(stdout)
if len(matches) < 2 {
return ""
}
return string(matches[1])
}
// winMSIEnv is the environment variable that, if set, is the MSI file for the
// update command to install. It's passed like this so we can stop the
// tailscale.exe process from running before the msiexec process runs and tries
// to overwrite ourselves.
const winMSIEnv = "TS_UPDATE_WIN_MSI"
const (
// winMSIEnv is the environment variable that, if set, is the MSI file for
// the update command to install. It's passed like this so we can stop the
// tailscale.exe process from running before the msiexec process runs and
// tries to overwrite ourselves.
winMSIEnv = "TS_UPDATE_WIN_MSI"
// winExePathEnv is the environment variable that is set along with
// winMSIEnv and carries the full path of the calling tailscale.exe binary.
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
// install is complete.
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
)
var (
verifyAuthenticode func(string) error // or nil on non-Windows
markTempFileFunc func(string) error // or nil on non-Windows
verifyAuthenticode func(string) error // or nil on non-Windows
markTempFileFunc func(string) error // or nil on non-Windows
launchTailscaleAsWinGUIUser func(string) error // or nil on non-Windows
)
func (up *Updater) updateWindows() error {
if msi := os.Getenv(winMSIEnv); msi != "" {
// stdout/stderr from this part of the install could be lost since the
// parent tailscaled is replaced. Create a temp log file to have some
// output to debug with in case update fails.
close, err := up.switchOutputToFile()
if err != nil {
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
} else {
defer close.Close()
}
up.Logf("installing %v ...", msi)
if err := up.installMSI(msi); err != nil {
up.Logf("MSI install failed: %v", err)
return err
}
up.Logf("relaunching tailscale-ipn.exe...")
exePath := os.Getenv(winExePathEnv)
if exePath == "" {
up.Logf("env var %q not passed to installer binary copy", winExePathEnv)
return fmt.Errorf("env var %q not passed to installer binary copy", winExePathEnv)
}
if err := launchTailscaleAsWinGUIUser(exePath); err != nil {
up.Logf("Failed to re-launch tailscale after update: %v", err)
return err
}
up.Logf("success.")
return nil
}
if !winutil.IsCurrentProcessElevated() {
return errors.New(`update must be run as Administrator
you can run the command prompt as Administrator one of these ways:
* right-click cmd.exe, select 'Run as administrator'
* 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
@@ -686,13 +737,9 @@ func (up *Updater) updateWindows() error {
if arch == "386" {
arch = "x86"
}
if !up.confirm(ver) {
return nil
}
if !winutil.IsCurrentProcessElevated() {
return errors.New("must be run as Administrator")
}
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
msiDir := filepath.Join(tsDir, "MSICache")
@@ -704,6 +751,7 @@ func (up *Updater) updateWindows() error {
if err := os.MkdirAll(msiDir, 0700); err != nil {
return err
}
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
@@ -717,7 +765,7 @@ func (up *Updater) updateWindows() error {
up.Logf("authenticode verification succeeded")
up.Logf("making tailscale.exe copy to switch to...")
selfCopy, err := makeSelfCopy()
selfOrig, selfCopy, err := makeSelfCopy()
if err != nil {
return err
}
@@ -725,9 +773,9 @@ func (up *Updater) updateWindows() error {
up.Logf("running tailscale.exe copy for final install...")
cmd := exec.Command(selfCopy, "update")
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig)
cmd.Stdout = up.Stderr
cmd.Stderr = up.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
return err
@@ -738,18 +786,44 @@ func (up *Updater) updateWindows() error {
panic("unreachable")
}
func (up *Updater) switchOutputToFile() (io.Closer, error) {
var logFilePath string
exePath, err := os.Executable()
if err != nil {
logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log")
} else {
logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log"
}
up.Logf("writing update output to %q", logFilePath)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, err
}
up.Logf = func(m string, args ...any) {
fmt.Fprintf(logFile, m+"\n", args...)
}
up.Stdout = logFile
up.Stderr = logFile
return logFile, nil
}
func (up *Updater) installMSI(msi string) error {
var err error
for tries := 0; tries < 2; tries++ {
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
// TS_NOLAUNCH: don't automatically launch the app after install.
// We will launch it explicitly as the current GUI user afterwards.
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn", "TS_NOLAUNCH=true")
cmd.Dir = filepath.Dir(msi)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
if err == nil {
break
}
up.Logf("Install attempt failed: %v", err)
uninstallVersion := version.Short()
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
uninstallVersion = v
@@ -757,8 +831,8 @@ func (up *Updater) installMSI(msi string) error {
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
up.Logf("msiexec uninstall: %v", err)
@@ -766,6 +840,30 @@ func (up *Updater) installMSI(msi string) error {
return err
}
// cleanupOldDownloads removes all files matching glob (see filepath.Glob).
// Only regular files are removed, so the glob must match specific files and
// not directories.
func (up *Updater) cleanupOldDownloads(glob string) {
matches, err := filepath.Glob(glob)
if err != nil {
up.Logf("cleaning up old downloads: %v", err)
return
}
for _, m := range matches {
s, err := os.Lstat(m)
if err != nil {
up.Logf("cleaning up old downloads: %v", err)
continue
}
if !s.Mode().IsRegular() {
continue
}
if err := os.Remove(m); err != nil {
up.Logf("cleaning up old downloads: %v", err)
}
}
}
func msiUUIDForVersion(ver string) string {
arch := runtime.GOARCH
if arch == "386" {
@@ -779,30 +877,30 @@ func msiUUIDForVersion(ver string) string {
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
}
func makeSelfCopy() (tmpPathExe string, err error) {
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
selfExe, err := os.Executable()
if err != nil {
return "", err
return "", "", err
}
f, err := os.Open(selfExe)
if err != nil {
return "", err
return "", "", err
}
defer f.Close()
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
if err != nil {
return "", err
return "", "", err
}
if f := markTempFileFunc; f != nil {
if err := f(f2.Name()); err != nil {
return "", err
return "", "", err
}
}
if _, err := io.Copy(f2, f); err != nil {
f2.Close()
return "", err
return "", "", err
}
return f2.Name(), f2.Close()
return selfExe, f2.Name(), f2.Close()
}
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
@@ -834,27 +932,37 @@ 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.Stdout = os.Stdout
cmd.Stderr = os.Stderr
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 {
// 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
@@ -862,10 +970,6 @@ func (up *Updater) updateLinuxBinary() error {
if !up.confirm(ver) {
return nil
}
// Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil {
return err
}
dlPath, err := up.downloadLinuxTarball(ver)
if err != nil {
@@ -894,7 +998,7 @@ func (up *Updater) updateLinuxBinary() error {
func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
dlDir, err := os.UserCacheDir()
if err != nil {
return "", err
dlDir = os.TempDir()
}
dlDir = filepath.Join(dlDir, "tailscale-update")
if err := os.MkdirAll(dlDir, 0700); err != nil {
@@ -970,6 +1074,135 @@ 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 (up *Updater) updateUnraid() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on Unraid is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "plugin check tailscale.plg && plugin update tailscale.plg"`, err)
}
}()
// We need to run `plugin check` for the latest tailscale.plg to get
// downloaded. Unfortunately, the output of this command does not contain
// the latest tailscale version available. So we'll parse the downloaded
// tailscale.plg file manually below.
out, err := exec.Command("plugin", "check", "tailscale.plg").CombinedOutput()
if err != nil {
return fmt.Errorf("failed to check if Tailscale plugin is upgradable: %w, output: %q", err, out)
}
// Note: 'plugin check' downloads plugins to /tmp/plugins.
// The installed .plg files are in /boot/config/plugins/, but the pending
// ones are in /tmp/plugins. We should parse the pending file downloaded by
// 'plugin check'.
latest, err := parseUnraidPluginVersion("/tmp/plugins/tailscale.plg")
if err != nil {
return fmt.Errorf("failed to find latest Tailscale version in /boot/config/plugins/tailscale.plg: %w", err)
}
if !up.confirm(latest) {
return nil
}
up.Logf("c2n: running 'plugin update tailscale.plg'")
cmd := exec.Command("plugin", "update", "tailscale.plg")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale plugin update: %w", err)
}
return nil
}
func parseUnraidPluginVersion(plgPath string) (string, error) {
plg, err := os.ReadFile(plgPath)
if err != nil {
return "", err
}
re := regexp.MustCompile(`<FILE Name="/boot/config/plugins/tailscale/tailscale_(\d+\.\d+\.\d+)_[a-z0-9]+.tgz">`)
match := re.FindStringSubmatch(string(plg))
if len(match) < 2 {
return "", errors.New("version not found in plg file")
}
return match[1], 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

@@ -11,6 +11,8 @@ import (
"maps"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
)
@@ -84,84 +86,6 @@ func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
}
}
func TestParseSoftwareupdateList(t *testing.T) {
tests := []struct {
name string
input []byte
want string
}{
{
name: "update-at-end-of-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
* Label: Tailscale-1.23.4
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
`),
want: "Tailscale-1.23.4",
},
{
name: "update-in-middle-of-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: Tailscale-1.23.5000
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
`),
want: "Tailscale-1.23.5000",
},
{
name: "update-not-in-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
`),
want: "",
},
{
name: "decoy-in-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: Malware-1.0
Title: * Label: Tailscale-0.99.0, Version: 1.0, Size: 968K, Recommended: NOT REALLY TBH,
`),
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := parseSoftwareupdateList(test.input)
if test.want != got {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
func TestUpdateYUMRepoTrack(t *testing.T) {
tests := []struct {
desc string
@@ -761,3 +685,141 @@ func TestWriteFileSymlink(t *testing.T) {
}
}
}
func TestCleanupOldDownloads(t *testing.T) {
tests := []struct {
desc string
before []string
symlinks map[string]string
glob string
after []string
}{
{
desc: "MSIs",
before: []string{
"MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.1.0.msi",
"MSICache/readme.txt",
},
glob: "MSICache/*.msi",
after: []string{
"MSICache/readme.txt",
},
},
{
desc: "SPKs",
before: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
"tmp/tailscale-update-2/tailscale-1.1.0.spk",
"tmp/readme.txt",
"tmp/tailscale-update-3",
"tmp/tailscale-update-4/tailscale-1.3.0",
},
glob: "tmp/tailscale-update*/*.spk",
after: []string{
"tmp/readme.txt",
"tmp/tailscale-update-3",
"tmp/tailscale-update-4/tailscale-1.3.0",
},
},
{
desc: "empty-target",
before: []string{},
glob: "tmp/tailscale-update*/*.spk",
after: []string{},
},
{
desc: "keep-dirs",
before: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
},
glob: "tmp/tailscale-update*",
after: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
},
},
{
desc: "no-follow-symlinks",
before: []string{
"MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.1.0.msi",
"MSICache/readme.txt",
},
symlinks: map[string]string{
"MSICache/tailscale-1.3.0.msi": "MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.4.0.msi": "MSICache/readme.txt",
},
glob: "MSICache/*.msi",
after: []string{
"MSICache/tailscale-1.3.0.msi",
"MSICache/tailscale-1.4.0.msi",
"MSICache/readme.txt",
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
dir := t.TempDir()
for _, p := range tt.before {
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, p), []byte(tt.desc), 0600); err != nil {
t.Fatal(err)
}
}
for from, to := range tt.symlinks {
if err := os.Symlink(filepath.Join(dir, to), filepath.Join(dir, from)); err != nil {
t.Fatal(err)
}
}
up := &Updater{Arguments: Arguments{Logf: t.Logf}}
up.cleanupOldDownloads(filepath.Join(dir, tt.glob))
var after []string
if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
after = append(after, strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(dir)+"/"))
}
return nil
}); err != nil {
t.Fatal(err)
}
sort.Strings(after)
sort.Strings(tt.after)
if !slices.Equal(after, tt.after) {
t.Errorf("got files after cleanup: %q, want: %q", after, tt.after)
}
})
}
}
func TestParseUnraidPluginVersion(t *testing.T) {
tests := []struct {
plgPath string
wantVer string
wantErr string
}{
{plgPath: "testdata/tailscale-1.52.0.plg", wantVer: "1.52.0"},
{plgPath: "testdata/tailscale-1.54.0.plg", wantVer: "1.54.0"},
{plgPath: "testdata/tailscale-nover.plg", wantErr: "version not found in plg file"},
{plgPath: "testdata/tailscale-nover-path-mentioned.plg", wantErr: "version not found in plg file"},
}
for _, tt := range tests {
t.Run(tt.plgPath, func(t *testing.T) {
got, err := parseUnraidPluginVersion(tt.plgPath)
if got != tt.wantVer {
t.Errorf("got version: %q, want %q", got, tt.wantVer)
}
var gotErr string
if err != nil {
gotErr = err.Error()
}
if gotErr != tt.wantErr {
t.Errorf("got error: %q, want %q", gotErr, tt.wantErr)
}
})
}
}

View File

@@ -7,6 +7,14 @@
package clientupdate
import (
"errors"
"fmt"
"os/exec"
"os/user"
"path/filepath"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"tailscale.com/util/winutil/authenticode"
)
@@ -14,6 +22,7 @@ import (
func init() {
markTempFileFunc = markTempFileWindows
verifyAuthenticode = verifyTailscale
launchTailscaleAsWinGUIUser = launchTailscaleAsGUIUser
}
func markTempFileWindows(name string) error {
@@ -26,3 +35,50 @@ const certSubjectTailscale = "Tailscale Inc."
func verifyTailscale(path string) error {
return authenticode.Verify(path, certSubjectTailscale)
}
func launchTailscaleAsGUIUser(exePath string) error {
exePath = filepath.Join(filepath.Dir(exePath), "tailscale-ipn.exe")
var token windows.Token
if u, err := user.Current(); err == nil && u.Name == "SYSTEM" {
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)
cmd.SysProcAttr = &syscall.SysProcAttr{
Token: syscall.Token(token),
HideWindow: true,
}
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

@@ -1,3 +1,3 @@
-----BEGIN ROOT PUBLIC KEY-----
Muw5GkO5mASsJ7k6kS+svfuanr6XcW9I7fPGtyqOTeI=
ZjjKhUHBtLNRSO1dhOTjrXJGJ8lDe1594WM2XDuheVQ=
-----END ROOT PUBLIC KEY-----

View File

@@ -0,0 +1,115 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN>
<PLUGIN
name="tailscale"
author="Derek Kaser"
version="2023.11.01"
pluginURL="https://raw.githubusercontent.com/dkaser/unraid-tailscale/main/plugin/tailscale.plg"
launch="Settings/Tailscale"
support="https://forums.unraid.net/topic/136889-plugin-tailscale/"
>
<CHANGES>
<![CDATA[
###2023.11.01###
- Update Tailscale to 1.52.0 (new checksum from upstream package server)
###2023.10.31###
- Update Tailscale to 1.52.0
###2023.10.29###
- Update Tailscale to 1.50.1
- Fix nginx hang when Tailscale restarts
###2023.09.26###
- Update Tailscale to 1.50.0
- New Tailscale web interface
###2023.09.14a###
- Update Tailscale to 1.48.2
###2023.08.22###
- Update Tailscale to 1.48.1
For older releases, see https://github.com/dkaser/unraid-tailscale/releases
]]>
</CHANGES>
<FILE Name="/boot/config/plugins/tailscale/tailscale_1.52.0_amd64.tgz">
<URL>https://pkgs.tailscale.com/stable/tailscale_1.52.0_amd64.tgz</URL>
<MD5>b4d15d9908737e08e3f95ed5104603ce</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-tailscale-utils/releases/download/1.4.1/unraid-tailscale-utils-1.4.1-noarch-1.txz</URL>
<MD5>7095ab4b88b34d8f5da6483865883267</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-plugin-diagnostics/releases/download/1.2.2/unraid-plugin-diagnostics-1.2.2-noarch-1.txz</URL>
<MD5>9d358575499305889962d83ebd90c20c</MD5>
</FILE>
<!--
The 'install' script.
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
if [ -d "/usr/local/emhttp/plugins/tailscale" ]; then
rm -rf /usr/local/emhttp/plugins/tailscale
fi
upgradepkg --install-new /boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz
upgradepkg --install-new --reinstall /boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz
mkdir -p /usr/local/emhttp/plugins/tailscale/bin
tar xzf /boot/config/plugins/tailscale/tailscale_1.52.0_amd64.tgz --strip-components 1 -C /usr/local/emhttp/plugins/tailscale/bin
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled
mkdir -p /var/local/emhttp/plugins/tailscale
echo "VERSION=2023.11.01" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
# start tailscaled
/usr/local/emhttp/plugins/tailscale/restart.sh
# cleanup old versions
rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz
rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '1.4.1')
rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null | grep -v '1.2.2')
rm -f $(ls /boot/config/plugins/tailscale/*.tgz 2>/dev/null | grep -v 'tailscale_1.52.0_amd64')
echo ""
echo "----------------------------------------------------"
echo " tailscale has been installed."
echo " Version: 2023.11.01"
echo "----------------------------------------------------"
echo ""
]]>
</INLINE>
</FILE>
<!--
The 'remove' script.
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
# Stop service
/etc/rc.d/rc.tailscale stop 2>/dev/null
rm /usr/local/sbin/tailscale
rm /usr/local/sbin/tailscaled
removepkg unraid-tailscale-utils-1.4.1
rm -rf /usr/local/emhttp/plugins/tailscale
rm -rf /boot/config/plugins/tailscale
]]>
</INLINE>
</FILE>
</PLUGIN>

View File

@@ -0,0 +1,112 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN>
<PLUGIN
name="tailscale"
author="Derek Kaser"
version="2023.11.18"
pluginURL="https://raw.githubusercontent.com/dkaser/unraid-tailscale/main/plugin/tailscale.plg"
launch="Settings/Tailscale"
support="https://forums.unraid.net/topic/136889-plugin-tailscale/"
>
<CHANGES>
<![CDATA[
###2023.11.18###
- Update Tailscale to 1.54.0
###2023.11.01###
- Update Tailscale to 1.52.0 (new checksum from upstream package server)
###2023.10.31###
- Update Tailscale to 1.52.0
###2023.10.29###
- Update Tailscale to 1.50.1
- Fix nginx hang when Tailscale restarts
###2023.09.26###
- Update Tailscale to 1.50.0
- New Tailscale web interface
For older releases, see https://github.com/dkaser/unraid-tailscale/releases
]]>
</CHANGES>
<FILE Name="/boot/config/plugins/tailscale/tailscale_1.54.0_amd64.tgz">
<URL>https://pkgs.tailscale.com/stable/tailscale_1.54.0_amd64.tgz</URL>
<MD5>20187743e0c1c1a0d9fea47a10b6a9ba</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-tailscale-utils/releases/download/1.4.1/unraid-tailscale-utils-1.4.1-noarch-1.txz</URL>
<MD5>7095ab4b88b34d8f5da6483865883267</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-plugin-diagnostics/releases/download/1.2.2/unraid-plugin-diagnostics-1.2.2-noarch-1.txz</URL>
<MD5>9d358575499305889962d83ebd90c20c</MD5>
</FILE>
<!--
The 'install' script.
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
if [ -d "/usr/local/emhttp/plugins/tailscale" ]; then
rm -rf /usr/local/emhttp/plugins/tailscale
fi
upgradepkg --install-new /boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz
upgradepkg --install-new --reinstall /boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz
mkdir -p /usr/local/emhttp/plugins/tailscale/bin
tar xzf /boot/config/plugins/tailscale/tailscale_1.54.0_amd64.tgz --strip-components 1 -C /usr/local/emhttp/plugins/tailscale/bin
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled
mkdir -p /var/local/emhttp/plugins/tailscale
echo "VERSION=2023.11.18" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
# start tailscaled
/usr/local/emhttp/plugins/tailscale/restart.sh
# cleanup old versions
rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz
rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '1.4.1')
rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null | grep -v '1.2.2')
rm -f $(ls /boot/config/plugins/tailscale/*.tgz 2>/dev/null | grep -v 'tailscale_1.54.0_amd64')
echo ""
echo "----------------------------------------------------"
echo " tailscale has been installed."
echo " Version: 2023.11.18"
echo "----------------------------------------------------"
echo ""
]]>
</INLINE>
</FILE>
<!--
The 'remove' script.
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
# Stop service
/etc/rc.d/rc.tailscale stop 2>/dev/null
rm /usr/local/sbin/tailscale
rm /usr/local/sbin/tailscaled
removepkg unraid-tailscale-utils-1.4.1
rm -rf /usr/local/emhttp/plugins/tailscale
rm -rf /boot/config/plugins/tailscale
]]>
</INLINE>
</FILE>
</PLUGIN>

View File

@@ -0,0 +1,110 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN>
<PLUGIN
name="tailscale"
author="Derek Kaser"
version="2023.11.01"
pluginURL="https://raw.githubusercontent.com/dkaser/unraid-tailscale/main/plugin/tailscale.plg"
launch="Settings/Tailscale"
support="https://forums.unraid.net/topic/136889-plugin-tailscale/"
>
<CHANGES>
<![CDATA[
###2023.11.01###
- Update Tailscale to 1.52.0 (new checksum from upstream package server)
###2023.10.31###
- Update Tailscale to 1.52.0
###2023.10.29###
- Update Tailscale to 1.50.1
- Fix nginx hang when Tailscale restarts
###2023.09.26###
- Update Tailscale to 1.50.0
- New Tailscale web interface
###2023.09.14a###
- Update Tailscale to 1.48.2
###2023.08.22###
- Update Tailscale to 1.48.1
For older releases, see https://github.com/dkaser/unraid-tailscale/releases
]]>
</CHANGES>
<FILE Name="/boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-tailscale-utils/releases/download/1.4.1/unraid-tailscale-utils-1.4.1-noarch-1.txz</URL>
<MD5>7095ab4b88b34d8f5da6483865883267</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-plugin-diagnostics/releases/download/1.2.2/unraid-plugin-diagnostics-1.2.2-noarch-1.txz</URL>
<MD5>9d358575499305889962d83ebd90c20c</MD5>
</FILE>
<!--
The 'install' script.
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
if [ -d "/usr/local/emhttp/plugins/tailscale" ]; then
rm -rf /usr/local/emhttp/plugins/tailscale
fi
upgradepkg --install-new /boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz
upgradepkg --install-new --reinstall /boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz
mkdir -p /usr/local/emhttp/plugins/tailscale/bin
tar xzf /boot/config/plugins/tailscale/tailscale_1.52.0_amd64.tgz --strip-components 1 -C /usr/local/emhttp/plugins/tailscale/bin
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled
mkdir -p /var/local/emhttp/plugins/tailscale
echo "VERSION=2023.11.01" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
# start tailscaled
/usr/local/emhttp/plugins/tailscale/restart.sh
# cleanup old versions
rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz
rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '1.4.1')
rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null | grep -v '1.2.2')
rm -f $(ls /boot/config/plugins/tailscale/*.tgz 2>/dev/null | grep -v 'tailscale_1.52.0_amd64')
echo ""
echo "----------------------------------------------------"
echo " tailscale has been installed."
echo " Version: 2023.11.01"
echo "----------------------------------------------------"
echo ""
]]>
</INLINE>
</FILE>
<!--
The 'remove' script.
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
# Stop service
/etc/rc.d/rc.tailscale stop 2>/dev/null
rm /usr/local/sbin/tailscale
rm /usr/local/sbin/tailscaled
removepkg unraid-tailscale-utils-1.4.1
rm -rf /usr/local/emhttp/plugins/tailscale
rm -rf /boot/config/plugins/tailscale
]]>
</INLINE>
</FILE>
</PLUGIN>

View File

@@ -0,0 +1,102 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN>
<PLUGIN
name="tailscale"
author="Derek Kaser"
version="2023.11.01"
pluginURL="https://raw.githubusercontent.com/dkaser/unraid-tailscale/main/plugin/tailscale.plg"
launch="Settings/Tailscale"
support="https://forums.unraid.net/topic/136889-plugin-tailscale/"
>
<CHANGES>
<![CDATA[
###2023.10.29###
- Update Tailscale to 1.50.1
- Fix nginx hang when Tailscale restarts
###2023.09.26###
- Update Tailscale to 1.50.0
- New Tailscale web interface
###2023.09.14a###
- Update Tailscale to 1.48.2
###2023.08.22###
- Update Tailscale to 1.48.1
For older releases, see https://github.com/dkaser/unraid-tailscale/releases
]]>
</CHANGES>
<FILE Name="/boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-tailscale-utils/releases/download/1.4.1/unraid-tailscale-utils-1.4.1-noarch-1.txz</URL>
<MD5>7095ab4b88b34d8f5da6483865883267</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-plugin-diagnostics/releases/download/1.2.2/unraid-plugin-diagnostics-1.2.2-noarch-1.txz</URL>
<MD5>9d358575499305889962d83ebd90c20c</MD5>
</FILE>
<!--
The 'install' script.
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
if [ -d "/usr/local/emhttp/plugins/tailscale" ]; then
rm -rf /usr/local/emhttp/plugins/tailscale
fi
upgradepkg --install-new /boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz
upgradepkg --install-new --reinstall /boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz
mkdir -p /usr/local/emhttp/plugins/tailscale/bin
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled
mkdir -p /var/local/emhttp/plugins/tailscale
echo "VERSION=2023.11.01" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
# start tailscaled
/usr/local/emhttp/plugins/tailscale/restart.sh
# cleanup old versions
rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz
rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '1.4.1')
rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null | grep -v '1.2.2')
echo ""
echo "----------------------------------------------------"
echo " tailscale has been installed."
echo " Version: 2023.11.01"
echo "----------------------------------------------------"
echo ""
]]>
</INLINE>
</FILE>
<!--
The 'remove' script.
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
# Stop service
/etc/rc.d/rc.tailscale stop 2>/dev/null
rm /usr/local/sbin/tailscale
rm /usr/local/sbin/tailscaled
removepkg unraid-tailscale-utils-1.4.1
rm -rf /usr/local/emhttp/plugins/tailscale
rm -rf /boot/config/plugins/tailscale
]]>
</INLINE>
</FILE>
</PLUGIN>

View File

@@ -13,14 +13,15 @@
//
// - TS_AUTHKEY: the authkey to use for login.
// - TS_HOSTNAME: the hostname to request for the node.
// - TS_ROUTES: subnet routes to advertise.
// - TS_ROUTES: subnet routes to advertise. To accept routes, use TS_EXTRA_ARGS to pass in --accept-routes.
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
// destination.
// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given
// destination.
// destination defined by an IP.
// - TS_TAILNET_TARGET_FQDN: proxy all incoming non-Tailscale traffic to the given
// destination defined by a MagicDNS name.
// - TS_TAILSCALED_EXTRA_ARGS: extra arguments to 'tailscaled'.
// - TS_EXTRA_ARGS: extra arguments to 'tailscale login', these are not
// reset on restart.
// - TS_EXTRA_ARGS: extra arguments to 'tailscale up'.
// - TS_USERSPACE: run with userspace networking (the default)
// instead of kernel networking.
// - TS_STATE_DIR: the directory in which to store tailscaled
@@ -36,15 +37,9 @@
// - TS_SOCKET: the path where the tailscaled LocalAPI socket should
// be created.
// - TS_AUTH_ONCE: if true, only attempt to log in if not already
// logged in. If false, forcibly log in every time the container starts.
// The default until 1.50.0 was false, but that was misleading: until
// 1.50, containerboot used `tailscale up` which would ignore an authkey
// argument if there was already a node key. Effectively, this behaved
// as though TS_AUTH_ONCE were always true.
// In 1.50.0 the change was made to use `tailscale login` instead of `up`,
// and login will reauthenticate every time it is given an authkey.
// In 1.50.1 we set the TS_AUTH_ONCE to true, to match the previously
// observed behavior.
// logged in. If false (the default, for backwards
// compatibility), forcibly log in every time the
// container starts.
// - TS_SERVE_CONFIG: if specified, is the file path where the ipn.ServeConfig is located.
// It will be applied once tailscaled is up and running. If the file contains
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
@@ -76,6 +71,7 @@ import (
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -84,33 +80,44 @@ import (
"golang.org/x/sys/unix"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/ptr"
"tailscale.com/util/deephash"
"tailscale.com/util/linuxfw"
)
func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
return linuxfw.NewFakeIPTablesRunner(), nil
}
return linuxfw.New(logf)
}
func main() {
log.SetPrefix("boot: ")
tailscale.I_Acknowledge_This_API_Is_Unstable = true
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnv("TS_ROUTES", ""),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultBool("TS_ACCEPT_DNS", false),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", true),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnv("TS_ROUTES", ""),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultBool("TS_ACCEPT_DNS", false),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
}
if cfg.ProxyTo != "" && cfg.UserspaceMode {
@@ -120,13 +127,19 @@ func main() {
if cfg.TailnetTargetIP != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetFQDN != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetFQDN != "" && cfg.TailnetTargetIP != "" {
log.Fatal("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
}
if !cfg.UserspaceMode {
if err := ensureTunFile(cfg.Root); err != nil {
log.Fatalf("Unable to create tuntap device file: %v", err)
}
if cfg.ProxyTo != "" || cfg.Routes != "" || cfg.TailnetTargetIP != "" {
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.TailnetTargetIP, cfg.Routes); err != nil {
if cfg.ProxyTo != "" || cfg.Routes != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" {
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.TailnetTargetIP, cfg.TailnetTargetFQDN, cfg.Routes); err != nil {
log.Printf("Failed to enable IP forwarding: %v", err)
log.Printf("To run tailscale as a proxy or router container, IP forwarding must be enabled.")
if cfg.InKubernetes {
@@ -179,10 +192,16 @@ func main() {
}
}
client, daemonPid, err := startTailscaled(bootCtx, cfg)
client, daemonProcess, err := startTailscaled(bootCtx, cfg)
if err != nil {
log.Fatalf("failed to bring up tailscale: %v", err)
}
killTailscaled := func() {
if err := daemonProcess.Signal(unix.SIGTERM); err != nil {
log.Fatalf("error shutting tailscaled down: %v", err)
}
}
defer killTailscaled()
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState)
if err != nil {
@@ -203,7 +222,7 @@ func main() {
}
didLogin = true
w.Close()
if err := tailscaleLogin(bootCtx, cfg); err != nil {
if err := tailscaleUp(bootCtx, cfg); err != nil {
return fmt.Errorf("failed to auth tailscale: %v", err)
}
w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState)
@@ -250,13 +269,15 @@ authLoop:
w.Close()
ctx, cancel := context.WithCancel(context.Background()) // no deadline now that we're in steady state
ctx, cancel := contextWithExitSignalWatch()
defer cancel()
// Now that we are authenticated, we can set/reset any of the
// settings that we need to.
if err := tailscaleSet(ctx, cfg); err != nil {
log.Fatalf("failed to auth tailscale: %v", err)
if cfg.AuthOnce {
// Now that we are authenticated, we can set/reset any of the
// settings that we need to.
if err := tailscaleSet(ctx, cfg); err != nil {
log.Fatalf("failed to auth tailscale: %v", err)
}
}
if cfg.ServeConfigPath != "" {
@@ -283,96 +304,172 @@ authLoop:
}
var (
wantProxy = cfg.ProxyTo != "" || cfg.TailnetTargetIP != ""
wantProxy = cfg.ProxyTo != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != ""
wantDeviceInfo = cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch
startupTasksDone = false
currentIPs deephash.Sum // tailscale IPs assigned to device
currentDeviceInfo deephash.Sum // device ID and fqdn
currentEgressIPs deephash.Sum
certDomain = new(atomic.Pointer[string])
certDomainChanged = make(chan bool, 1)
)
if cfg.ServeConfigPath != "" {
go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client)
}
for {
n, err := w.Next()
var nfr linuxfw.NetfilterRunner
if wantProxy {
nfr, err = newNetfilterRunner(log.Printf)
if err != nil {
log.Fatalf("error creating new netfilter runner: %v", err)
}
}
notifyChan := make(chan ipn.Notify)
errChan := make(chan error)
go func() {
for {
n, err := w.Next()
if err != nil {
errChan <- err
break
} else {
notifyChan <- n
}
}
}()
var wg sync.WaitGroup
runLoop:
for {
select {
case <-ctx.Done():
// Although killTailscaled() is deferred earlier, if we
// have started the reaper defined below, we need to
// kill tailscaled and let reaper clean up child
// processes.
killTailscaled()
break runLoop
case err := <-errChan:
log.Fatalf("failed to read from tailscaled: %v", err)
}
if n.State != nil && *n.State != ipn.Running {
// Something's gone wrong and we've left the authenticated state.
// Our container image never recovered gracefully from this, and the
// control flow required to make it work now is hard. So, just crash
// the container and rely on the container runtime to restart us,
// whereupon we'll go through initial auth again.
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
}
if n.NetMap != nil {
addrs := n.NetMap.SelfNode.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs); err != nil {
log.Fatalf("installing ingress proxy rules: %v", err)
}
case n := <-notifyChan:
if n.State != nil && *n.State != ipn.Running {
// Something's gone wrong and we've left the authenticated state.
// Our container image never recovered gracefully from this, and the
// control flow required to make it work now is hard. So, just crash
// the container and rely on the container runtime to restart us,
// whereupon we'll go through initial auth again.
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
}
if cfg.ServeConfigPath != "" && len(n.NetMap.DNS.CertDomains) > 0 {
cd := n.NetMap.DNS.CertDomains[0]
prev := certDomain.Swap(ptr.To(cd))
if prev == nil || *prev != cd {
select {
case certDomainChanged <- true:
default:
if n.NetMap != nil {
addrs := n.NetMap.SelfNode.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
if cfg.TailnetTargetFQDN != "" {
var (
egressAddrs []netip.Prefix
newCurentEgressIPs deephash.Sum
egressIPsHaveChanged bool
node tailcfg.NodeView
nodeFound bool
)
for _, n := range n.NetMap.Peers {
if strings.EqualFold(n.Name(), cfg.TailnetTargetFQDN) {
node = n
nodeFound = true
break
}
}
if !nodeFound {
log.Printf("Tailscale node %q not found; it either does not exist, or not reachable because of ACLs", cfg.TailnetTargetFQDN)
break
}
egressAddrs = node.Addresses().AsSlice()
newCurentEgressIPs = deephash.Hash(&egressAddrs)
egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
if egressIPsHaveChanged && len(egressAddrs) > 0 {
for _, egressAddr := range egressAddrs {
ea := egressAddr.Addr()
// TODO (irbekrm): make it work for IPv6 too.
if ea.Is6() {
log.Println("Not installing egress forwarding rules for IPv6 as this is currently not supported")
continue
}
log.Printf("Installing forwarding rules for destination %v", ea.String())
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
}
}
}
currentEgressIPs = newCurentEgressIPs
}
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs, nfr); err != nil {
log.Fatalf("installing ingress proxy rules: %v", err)
}
}
if cfg.ServeConfigPath != "" && len(n.NetMap.DNS.CertDomains) > 0 {
cd := n.NetMap.DNS.CertDomains[0]
prev := certDomain.Swap(ptr.To(cd))
if prev == nil || *prev != cd {
select {
case certDomainChanged <- true:
default:
}
}
}
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) > 0 {
log.Printf("Installing forwarding rules for destination %v", cfg.TailnetTargetIP)
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
log.Fatalf("installing egress proxy rules: %v", err)
}
}
currentIPs = newCurrentIPs
deviceInfo := []any{n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name()}
if cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" && deephash.Update(&currentDeviceInfo, &deviceInfo) {
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
log.Fatalf("storing device ID in kube secret: %v", err)
}
}
}
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) > 0 {
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs); err != nil {
log.Fatalf("installing egress proxy rules: %v", err)
}
}
currentIPs = newCurrentIPs
if !startupTasksDone {
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
// This log message is used in tests to detect when all
// post-auth configuration is done.
log.Println("Startup complete, waiting for shutdown signal")
startupTasksDone = true
deviceInfo := []any{n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name()}
if cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" && deephash.Update(&currentDeviceInfo, &deviceInfo) {
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
log.Fatalf("storing device ID in kube secret: %v", err)
}
}
}
if !startupTasksDone {
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
// This log message is used in tests to detect when all
// post-auth configuration is done.
log.Println("Startup complete, waiting for shutdown signal")
startupTasksDone = true
// Reap all processes, since we are PID1 and need to collect zombies. We can
// only start doing this once we've stopped shelling out to things
// `tailscale up`, otherwise this goroutine can reap the CLI subprocesses
// and wedge bringup.
reaper := func() {
defer wg.Done()
for {
var status unix.WaitStatus
pid, err := unix.Wait4(-1, &status, 0, nil)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
log.Fatalf("Waiting for exited processes: %v", err)
}
if pid == daemonProcess.Pid {
log.Printf("Tailscaled exited")
os.Exit(0)
}
}
// Reap all processes, since we are PID1 and need to collect zombies. We can
// only start doing this once we've stopped shelling out to things
// `tailscale up`, otherwise this goroutine can reap the CLI subprocesses
// and wedge bringup.
go func() {
for {
var status unix.WaitStatus
pid, err := unix.Wait4(-1, &status, 0, nil)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
log.Fatalf("Waiting for exited processes: %v", err)
}
if pid == daemonPid {
log.Printf("Tailscaled exited")
os.Exit(0)
}
}
}()
wg.Add(1)
go reaper()
}
}
}
}
wg.Wait()
}
// watchServeConfigChanges watches path for changes, and when it sees one, reads
@@ -385,19 +482,20 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
panic("cd must not be nil")
}
var tickChan <-chan time.Time
w, err := fsnotify.NewWatcher()
if err != nil {
var eventChan <-chan fsnotify.Event
if w, err := fsnotify.NewWatcher(); err != nil {
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
tickChan = ticker.C
} else {
defer w.Close()
if err := w.Add(filepath.Dir(path)); err != nil {
log.Fatalf("failed to add fsnotify watch: %v", err)
}
eventChan = w.Events
}
if err := w.Add(filepath.Dir(path)); err != nil {
log.Fatalf("failed to add fsnotify watch: %v", err)
}
var certDomain string
var prevServeConfig *ipn.ServeConfig
for {
@@ -407,7 +505,7 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
case <-cdChanged:
certDomain = *certDomainAtomic.Load()
case <-tickChan:
case <-w.Events:
case <-eventChan:
// We can't do any reasonable filtering on the event because of how
// k8s handles these mounts. So just re-read the file and apply it
// if it's changed.
@@ -448,10 +546,8 @@ func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
return &sc, nil
}
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, int, error) {
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, *os.Process, error) {
args := tailscaledArgs(cfg)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, unix.SIGTERM, unix.SIGINT)
// tailscaled runs without context, since it needs to persist
// beyond the startup timeout in ctx.
cmd := exec.Command("tailscaled", args...)
@@ -462,13 +558,8 @@ func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient
}
log.Printf("Starting tailscaled")
if err := cmd.Start(); err != nil {
return nil, 0, fmt.Errorf("starting tailscaled failed: %v", err)
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
}
go func() {
<-sigCh
log.Printf("Received SIGTERM from container runtime, shutting down tailscaled")
cmd.Process.Signal(unix.SIGTERM)
}()
// Wait for the socket file to appear, otherwise API ops will racily fail.
log.Printf("Waiting for tailscaled socket")
@@ -491,7 +582,7 @@ func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient
UseSocketOnly: true,
}
return tsClient, cmd.Process.Pid, nil
return tsClient, cmd.Process, nil
}
// tailscaledArgs uses cfg to construct the argv for tailscaled.
@@ -528,29 +619,40 @@ func tailscaledArgs(cfg *settings) []string {
return args
}
// tailscaleLogin uses cfg to run 'tailscale login' everytime containerboot
// starts, or if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleLogin(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "login"}
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleUp(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "up"}
if cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
}
if cfg.AuthKey != "" {
args = append(args, "--authkey="+cfg.AuthKey)
}
if cfg.Routes != "" {
args = append(args, "--advertise-routes="+cfg.Routes)
}
if cfg.Hostname != "" {
args = append(args, "--hostname="+cfg.Hostname)
}
if cfg.ExtraArgs != "" {
args = append(args, strings.Fields(cfg.ExtraArgs)...)
}
log.Printf("Running 'tailscale login'")
log.Printf("Running 'tailscale up'")
cmd := exec.CommandContext(ctx, "tailscale", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale login failed: %v", err)
return fmt.Errorf("tailscale up failed: %v", err)
}
return nil
}
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
// options that are passed in via environment variables. This is run after the
// node is in Running state.
// node is in Running state and only if TS_AUTH_ONCE is set.
func tailscaleSet(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "set"}
if cfg.AcceptDNS {
@@ -594,7 +696,7 @@ func ensureTunFile(root string) error {
}
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, routes string) error {
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTargetFQDN, routes string) error {
var (
v4Forwarding, v6Forwarding bool
)
@@ -620,6 +722,11 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, routes string
v6Forwarding = true
}
}
// Currently we only proxy traffic to the IPv4 address of the tailnet
// target.
if tailnetTargetFQDN != "" {
v4Forwarding = true
}
if routes != "" {
for _, route := range strings.Split(routes, ",") {
cidr, err := netip.ParsePrefix(route)
@@ -662,16 +769,12 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, routes string
return nil
}
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
argv0 := "iptables"
if dst.Is6() {
argv0 = "ip6tables"
}
var local string
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
@@ -679,52 +782,30 @@ func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []net
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr().String()
local = pfx.Addr()
break
}
if local == "" {
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
// Technically, if the control server ever changes the IPs assigned to this
// node, we'll slowly accumulate iptables rules. This shouldn't happen, so
// for now we'll live with it.
// Set up a rule that ensures that all packets
// except for those received on tailscale0 interface is forwarded to
// destination address
cmdDNAT := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "!", "-i", "tailscale0", "-j", "DNAT", "--to-destination", dstStr)
cmdDNAT.Stdout = os.Stdout
cmdDNAT.Stderr = os.Stderr
if err := cmdDNAT.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
// Set up a rule that ensures that all packets sent to the destination
// address will have the proxy's IP set as source IP
cmdSNAT := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "POSTROUTING", "1", "--destination", dstStr, "-j", "SNAT", "--to-source", local)
cmdSNAT.Stdout = os.Stdout
cmdSNAT.Stderr = os.Stderr
if err := cmdSNAT.Run(); err != nil {
return fmt.Errorf("setting up SNAT via iptables failed: %w", err)
if err := nfr.AddSNATRuleForDst(local, dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
cmdClamp := exec.CommandContext(ctx, argv0, "-t", "mangle", "-A", "FORWARD", "-o", "tailscale0", "-p", "tcp", "-m", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu")
cmdClamp.Stdout = os.Stdout
cmdClamp.Stderr = os.Stderr
if err := cmdClamp.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
return nil
}
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
argv0 := "iptables"
if dst.Is6() {
argv0 = "ip6tables"
}
var local string
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
@@ -732,26 +813,17 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr().String()
local = pfx.Addr()
break
}
if local == "" {
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
// Technically, if the control server ever changes the IPs assigned to this
// node, we'll slowly accumulate iptables rules. This shouldn't happen, so
// for now we'll live with it.
cmd := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "-d", local, "-j", "DNAT", "--to-destination", dstStr)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
if err := nfr.AddDNATRule(local, dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
cmdClamp := exec.CommandContext(ctx, argv0, "-t", "mangle", "-A", "FORWARD", "-o", "tailscale0", "-p", "tcp", "-m", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu")
cmdClamp.Stdout = os.Stdout
cmdClamp.Stderr = os.Stderr
if err := cmdClamp.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
return nil
}
@@ -766,9 +838,13 @@ type settings struct {
// is done. This is typically a locally reachable IP.
ProxyTo string
// TailnetTargetIP is the destination IP to which all incoming
// non-Tailscale traffic should be proxied. If empty, no
// proxying is done. This is typically a Tailscale IP.
TailnetTargetIP string
// non-Tailscale traffic should be proxied. This is typically a
// Tailscale IP.
TailnetTargetIP string
// TailnetTargetFQDN is an MagicDNS name to which all incoming
// non-Tailscale traffic should be proxied. This must be a full Tailnet
// node FQDN.
TailnetTargetFQDN string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
@@ -813,3 +889,25 @@ func defaultBool(name string, defVal bool) bool {
}
return ret
}
// contextWithExitSignalWatch watches for SIGTERM/SIGINT signals. It returns a
// context that gets cancelled when a signal is received and a cancel function
// that can be called to free the resources when the watch should be stopped.
func contextWithExitSignalWatch() (context.Context, func()) {
closeChan := make(chan string)
ctx, cancel := context.WithCancel(context.Background())
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case <-signalChan:
cancel()
case <-closeChan:
return
}
}()
f := func() {
closeChan <- "goodbye"
}
return ctx, f
}

View File

@@ -129,22 +129,16 @@ func TestContainerBoot(t *testing.T) {
{
// Out of the box default: runs in userspace mode, ephemeral storage, interactive login.
Name: "no_args",
Env: map[string]string{
"TS_AUTH_ONCE": "false",
},
Env: nil,
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -152,21 +146,17 @@ func TestContainerBoot(t *testing.T) {
// Userspace mode, ephemeral storage, authkey provided on every run.
Name: "authkey",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_ONCE": "false",
"TS_AUTHKEY": "tskey-key",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -174,21 +164,17 @@ func TestContainerBoot(t *testing.T) {
// Userspace mode, ephemeral storage, authkey provided on every run.
Name: "authkey-old-flag",
Env: map[string]string{
"TS_AUTH_KEY": "tskey-key",
"TS_AUTH_ONCE": "false",
"TS_AUTH_KEY": "tskey-key",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -197,35 +183,30 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_STATE_DIR": filepath.Join(d, "tmp"),
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
{
Name: "routes",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
"TS_AUTH_ONCE": "false",
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
},
},
{
@@ -234,9 +215,6 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "0",
"proc/sys/net/ipv6/conf/all/forwarding": "0",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=1.2.3.0/24,10.20.30.0/24",
},
},
},
},
@@ -246,13 +224,12 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
},
},
{
@@ -261,9 +238,6 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "1",
"proc/sys/net/ipv6/conf/all/forwarding": "0",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=1.2.3.0/24,10.20.30.0/24",
},
},
},
},
@@ -273,13 +247,12 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "::/64,1::/64",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1::/64",
},
},
{
@@ -288,9 +261,6 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "0",
"proc/sys/net/ipv6/conf/all/forwarding": "1",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=::/64,1::/64",
},
},
},
},
@@ -300,13 +270,12 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "::/64,1.2.3.0/24",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1.2.3.0/24",
},
},
{
@@ -315,9 +284,6 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "1",
"proc/sys/net/ipv6/conf/all/forwarding": "1",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=::/64,1.2.3.0/24",
},
},
},
},
@@ -327,22 +293,16 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_DEST_IP": "1.2.3.4",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
"/usr/bin/iptables -t nat -I PREROUTING 1 -d 100.64.0.1 -j DNAT --to-destination 1.2.3.4",
"/usr/bin/iptables -t mangle -A FORWARD -o tailscale0 -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu",
},
},
},
},
@@ -352,23 +312,16 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_TAILNET_TARGET_IP": "100.99.99.99",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
"/usr/bin/iptables -t nat -I PREROUTING 1 ! -i tailscale0 -j DNAT --to-destination 100.99.99.99",
"/usr/bin/iptables -t nat -I POSTROUTING 1 --destination 100.99.99.99 -j SNAT --to-source 100.64.0.1",
"/usr/bin/iptables -t mangle -A FORWARD -o tailscale0 -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu",
},
},
},
},
@@ -389,7 +342,7 @@ func TestContainerBoot(t *testing.T) {
State: ptr.To(ipn.NeedsLogin),
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
},
{
@@ -405,7 +358,6 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_AUTH_ONCE": "false",
},
KubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -414,7 +366,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -422,9 +374,6 @@ func TestContainerBoot(t *testing.T) {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
@@ -443,22 +392,18 @@ func TestContainerBoot(t *testing.T) {
"TS_KUBE_SECRET": "",
"TS_STATE_DIR": filepath.Join(d, "tmp"),
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_ONCE": "false",
},
KubeSecret: map[string]string{},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
WantKubeSecret: map[string]string{},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
Notify: runningNotify,
WantKubeSecret: map[string]string{},
},
},
@@ -469,7 +414,6 @@ func TestContainerBoot(t *testing.T) {
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_ONCE": "false",
},
KubeSecret: map[string]string{},
KubeDenyPatch: true,
@@ -477,15 +421,12 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
WantKubeSecret: map[string]string{},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
Notify: runningNotify,
WantKubeSecret: map[string]string{},
},
},
@@ -515,7 +456,7 @@ func TestContainerBoot(t *testing.T) {
State: ptr.To(ipn.NeedsLogin),
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -539,7 +480,6 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_AUTH_ONCE": "false",
},
KubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -548,7 +488,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -556,9 +496,6 @@ func TestContainerBoot(t *testing.T) {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
@@ -591,20 +528,16 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"TS_SOCKS5_SERVER": "localhost:1080",
"TS_OUTBOUND_HTTP_PROXY_LISTEN": "localhost:8080",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --socks5-server=localhost:1080 --outbound-http-proxy-listen=localhost:8080",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -612,20 +545,16 @@ func TestContainerBoot(t *testing.T) {
Name: "dns",
Env: map[string]string{
"TS_ACCEPT_DNS": "true",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=true",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=true",
},
},
},
},
@@ -634,41 +563,47 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"TS_EXTRA_ARGS": "--widget=rotated",
"TS_TAILSCALED_EXTRA_ARGS": "--experiments=widgets",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --experiments=widgets",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --widget=rotated",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --widget=rotated",
},
},
{
}, {
Notify: runningNotify,
},
},
},
{
Name: "extra_args_accept_routes",
Env: map[string]string{
"TS_EXTRA_ARGS": "--accept-routes",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --accept-routes",
},
}, {
Notify: runningNotify,
},
},
},
{
Name: "hostname",
Env: map[string]string{
"TS_HOSTNAME": "my-server",
"TS_AUTH_ONCE": "false",
"TS_HOSTNAME": "my-server",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --hostname=my-server",
},
},
{
}, {
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --hostname=my-server",
},
},
},
},
@@ -694,6 +629,7 @@ func TestContainerBoot(t *testing.T) {
fmt.Sprintf("TS_TEST_SOCKET=%s", lapi.Path),
fmt.Sprintf("TS_SOCKET=%s", runningSockPath),
fmt.Sprintf("TS_TEST_ONLY_ROOT=%s", d),
fmt.Sprint("TS_TEST_FAKE_NETFILTER=true"),
}
for k, v := range test.Env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))

View File

@@ -2,11 +2,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519
W 💣 github.com/Microsoft/go-winio from tailscale.com/safesocket
W 💣 github.com/Microsoft/go-winio/internal/fs from github.com/Microsoft/go-winio
W 💣 github.com/Microsoft/go-winio/internal/socket from github.com/Microsoft/go-winio
W github.com/Microsoft/go-winio/internal/stringbuffer from github.com/Microsoft/go-winio/internal/fs
W github.com/Microsoft/go-winio/pkg/guid from github.com/Microsoft/go-winio+
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
@@ -17,13 +12,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/golang/protobuf/proto from github.com/matttproud/golang_protobuf_extensions/pbutil
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/tsweb
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
@@ -44,6 +39,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
LD github.com/prometheus/procfs from github.com/prometheus/client_golang/prometheus
LD github.com/prometheus/procfs/internal/fs from github.com/prometheus/procfs
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
@@ -79,22 +79,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
google.golang.org/protobuf/runtime/protoimpl from github.com/golang/protobuf/proto+
google.golang.org/protobuf/types/descriptorpb from google.golang.org/protobuf/reflect/protodesc
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/buffer+
gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/buffer
💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip+
gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs
💣 gvisor.dev/gvisor/pkg/gohacks from gvisor.dev/gvisor/pkg/state/wire+
gvisor.dev/gvisor/pkg/linewriter from gvisor.dev/gvisor/pkg/log
gvisor.dev/gvisor/pkg/log from gvisor.dev/gvisor/pkg/context+
gvisor.dev/gvisor/pkg/refs from gvisor.dev/gvisor/pkg/buffer
💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+
gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state
💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/atomicbitops+
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/header+
gvisor.dev/gvisor/pkg/tcpip/checksum from gvisor.dev/gvisor/pkg/buffer+
gvisor.dev/gvisor/pkg/tcpip/header from tailscale.com/net/packet
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
nhooyr.io/websocket from tailscale.com/cmd/derper+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
@@ -128,7 +112,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
tailscale.com/net/wsconn from tailscale.com/cmd/derper+
tailscale.com/paths from tailscale.com/client/tailscale
tailscale.com/safesocket from tailscale.com/client/tailscale
💣 tailscale.com/safesocket from tailscale.com/client/tailscale
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+
@@ -164,10 +148,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L tailscale.com/util/linuxfw from tailscale.com/net/netns
tailscale.com/util/mak from tailscale.com/syncs+
tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/set from tailscale.com/health+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/vizerror from tailscale.com/tsweb
tailscale.com/util/vizerror from tailscale.com/tsweb+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+
@@ -234,6 +219,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/tls from golang.org/x/crypto/acme+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+

View File

@@ -12,6 +12,7 @@ import (
"testing"
"tailscale.com/net/stun"
"tailscale.com/tstest/deptest"
)
func TestProdAutocertHostPolicy(t *testing.T) {
@@ -128,3 +129,14 @@ func TestNoContent(t *testing.T) {
})
}
}
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
},
}.Check(t)
}

View File

@@ -41,6 +41,7 @@ func startMeshWithHost(s *derp.Server, host string) error {
return err
}
c.MeshKey = s.MeshKey()
c.WatchConnectionChanges = true
// For meshed peers within a region, connect via VPC addresses.
c.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) {

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -0,0 +1,29 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v2
name: tailscale-operator
description: A Helm chart for Tailscale Kubernetes operator
home: https://github.com/tailscale/tailscale
keywords:
- "tailscale"
- "vpn"
- "ingress"
- "egress"
- "wireguard"
sources:
- https://github.com/tailscale/tailscale
type: application
maintainers:
- name: tailscale-maintainers
url: https://tailscale.com/
# version will be set to Tailscale repo tag (without 'v') at release time.
version: 0.1.0
# appVersion will be set to Tailscale repo tag at release time.
appVersion: "unstable"

View File

@@ -0,0 +1,26 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
{{ if eq .Values.apiServerProxyConfig.mode "true" }}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-auth-proxy
rules:
- apiGroups: [""]
resources: ["users", "groups"]
verbs: ["impersonate"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-auth-proxy
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: tailscale-auth-proxy
apiGroup: rbac.authorization.k8s.io
{{ end }}

View File

@@ -0,0 +1,90 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: apps/v1
kind: Deployment
metadata:
name: operator
namespace: {{ .Release.Namespace }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: operator
template:
metadata:
{{- with .Values.operatorConfig.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
app: operator
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: operator
{{- with .Values.operatorConfig.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: oauth
secret:
secretName: operator-oauth
containers:
- name: operator
{{- with .Values.operatorConfig.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.operatorConfig.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- $operatorTag:= printf ":%s" ( .Values.operatorConfig.image.tag | default .Chart.AppVersion )}}
image: {{ .Values.operatorConfig.image.repo }}{{- if .Values.operatorConfig.image.digest -}}{{ printf "@%s" .Values.operatorConfig.image.digest}}{{- else -}}{{ printf "%s" $operatorTag }}{{- end }}
imagePullPolicy: {{ .Values.operatorConfig.image.pullPolicy }}
env:
- name: OPERATOR_HOSTNAME
value: {{ .Values.operatorConfig.hostname }}
- name: OPERATOR_SECRET
value: operator
- name: OPERATOR_LOGGING
value: {{ .Values.operatorConfig.logging }}
- name: OPERATOR_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: CLIENT_ID_FILE
value: /oauth/client_id
- name: CLIENT_SECRET_FILE
value: /oauth/client_secret
{{- $proxyTag := printf ":%s" ( .Values.proxyConfig.image.tag | default .Chart.AppVersion )}}
- name: PROXY_IMAGE
value: {{ .Values.proxyConfig.image.repo }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }}
- name: PROXY_TAGS
value: {{ .Values.proxyConfig.defaultTags }}
- name: APISERVER_PROXY
value: "{{ .Values.apiServerProxyConfig.mode }}"
- name: PROXY_FIREWALL_MODE
value: {{ .Values.proxyConfig.firewallMode }}
volumeMounts:
- name: oauth
mountPath: /oauth
readOnly: true
{{- with .Values.operatorConfig.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operatorConfig.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operatorConfig.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,13 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
{{ if and .Values.oauth .Values.oauth.clientId -}}
apiVersion: v1
kind: Secret
metadata:
name: operator-oauth
namespace: {{ .Release.Namespace }}
stringData:
client_id: {{ .Values.oauth.clientId }}
client_secret: {{ .Values.oauth.clientSecret }}
{{- end -}}

View File

@@ -0,0 +1,60 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v1
kind: ServiceAccount
metadata:
name: operator
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-operator
rules:
- apiGroups: [""]
resources: ["events", "services", "services/status"]
verbs: ["*"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses", "ingresses/status"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-operator
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: tailscale-operator
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: operator
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["*"]
- apiGroups: ["apps"]
resources: ["statefulsets"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: operator
namespace: {{ .Release.Namespace }}
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: Role
name: operator
apiGroup: rbac.authorization.k8s.io

View File

@@ -0,0 +1,32 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v1
kind: ServiceAccount
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
subjects:
- kind: ServiceAccount
name: proxies
namespace: {{ .Release.Namespace }}
roleRef:
kind: Role
name: proxies
apiGroup: rbac.authorization.k8s.io

View File

@@ -0,0 +1,59 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# Operator oauth credentials. If set a Kubernetes Secret with the provided
# values will be created in the operator namespace. If unset a Secret named
# operator-oauth must be precreated.
oauth: {}
# clientId: ""
# clientSecret: ""
operatorConfig:
image:
repo: tailscale/k8s-operator
# Digest will be prioritized over tag. If neither are set appVersion will be
# used.
tag: ""
digest: ""
pullPolicy: Always
logging: "info"
hostname: "tailscale-operator"
nodeSelector:
kubernetes.io/os: linux
resources: {}
podAnnotations: {}
tolerations: []
affinity: {}
podSecurityContext: {}
securityContext: {}
# proxyConfig contains configuraton that will be applied to any ingress/egress
# proxies created by the operator.
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-egress
proxyConfig:
image:
repo: tailscale/tailscale
# Digest will be prioritized over tag. If neither are set appVersion will be
# used.
tag: ""
digest: ""
# ACL tag that operator will tag proxies with. Operator must be made owner of
# these tags
# https://tailscale.com/kb/1236/kubernetes-operator/?q=operator#setting-up-the-kubernetes-operator
defaultTags: tag:k8s
firewallMode: auto
# apiServerProxyConfig allows to configure whether the operator should expose
# Kubernetes API server.
# https://tailscale.com/kb/1236/kubernetes-operator/#accessing-the-kubernetes-control-plane-using-an-api-server-proxy
apiServerProxyConfig:
mode: "false" # "true", "false", "noauth"
imagePullSecrets: []

View File

@@ -151,8 +151,10 @@ spec:
value: tailscale/tailscale:unstable
- name: PROXY_TAGS
value: tag:k8s
- name: AUTH_PROXY
- name: APISERVER_PROXY
value: "false"
- name: PROXY_FIREWALL_MODE
value: auto
volumeMounts:
- name: oauth
mountPath: /oauth

View File

@@ -12,7 +12,6 @@ spec:
serviceAccountName: proxies
initContainers:
- name: sysctler
image: busybox
securityContext:
privileged: true
command: ["/bin/sh"]

View File

@@ -192,8 +192,15 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
}
}
addIngressBackend(ing.Spec.DefaultBackend, "/")
var tlsHost string // hostname or FQDN or empty
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
tlsHost = ing.Spec.TLS[0].Hosts[0]
}
for _, rule := range ing.Spec.Rules {
if rule.Host != "" {
// Host is optional, but if it's present it must match the TLS host
// otherwise we ignore the rule.
if rule.Host != "" && rule.Host != tlsHost {
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "rule with host %q ignored, unsupported", rule.Host)
continue
}
@@ -208,8 +215,8 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
tags = strings.Split(tstr, ",")
}
hostname := ing.Namespace + "-" + ing.Name + "-ingress"
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
hostname, _, _ = strings.Cut(ing.Spec.TLS[0].Hosts[0], ".")
if tlsHost != "" {
hostname, _, _ = strings.Cut(tlsHost, ".")
}
sts := &tailscaleSTSConfig{

View File

@@ -10,6 +10,7 @@ package main
import (
"context"
"os"
"regexp"
"strings"
"time"
@@ -52,6 +53,7 @@ func main() {
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
)
var opts []kzap.Opts
@@ -66,18 +68,27 @@ func main() {
zlog := kzap.NewRaw(opts...).Sugar()
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
// The operator can run either as a plain operator or it can
// additionally act as api-server proxy
// https://tailscale.com/kb/1236/kubernetes-operator/?q=kubernetes#accessing-the-kubernetes-control-plane-using-an-api-server-proxy.
mode := parseAPIProxyMode()
if mode == apiserverProxyModeDisabled {
hostinfo.SetApp("k8s-operator")
} else {
hostinfo.SetApp("k8s-operator-proxy")
}
s, tsClient := initTSNet(zlog)
defer s.Close()
restConfig := config.GetConfigOrDie()
maybeLaunchAPIServerProxy(zlog, restConfig, s)
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags)
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
}
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
// CLIENT_ID_FILE and CLIENT_SECRET_FILE environment variables to authenticate
// with Tailscale.
func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
hostinfo.SetApp("k8s-operator")
var (
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
@@ -179,7 +190,7 @@ waitOnline:
// runReconcilers starts the controller-runtime manager and registers the
// ServiceReconciler. It blocks forever.
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags string) {
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
var (
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
)
@@ -216,6 +227,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
operatorNamespace: tsNamespace,
proxyImage: image,
proxyPriorityClassName: priorityClassName,
tsFirewallMode: tsFirewallMode,
}
err = builder.
ControllerManagedBy(mgr).
@@ -228,6 +240,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
Client: mgr.GetClient(),
logger: zlog.Named("service-reconciler"),
isDefaultLoadBalancer: isDefaultLoadBalancer,
recorder: eventRecorder,
})
if err != nil {
startlog.Fatalf("could not create controller: %v", err)
@@ -310,3 +323,10 @@ func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
}
}
// isMagicDNSName reports whether name is a full tailnet node FQDN (with or
// without final dot).
func isMagicDNSName(name string) bool {
validMagicDNSName := regexp.MustCompile(`^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.ts\.net\.?$`)
return validMagicDNSName.MatchString(name)
}

View File

@@ -70,7 +70,12 @@ func TestLoadBalancerClass(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
@@ -154,6 +159,119 @@ func TestLoadBalancerClass(t *testing.T) {
}
expectEqual(t, fc, want)
}
func TestTailnetTargetFQDNAnnotation(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
tailnetTargetFQDN := "foo.bar.ts.net."
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
},
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: map[string]string{
"foo": "bar",
},
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
tailnetTargetFQDN: tailnetTargetFQDN,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
Finalizers: []string{"tailscale.com/finalizer"},
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
},
},
Spec: corev1.ServiceSpec{
ExternalName: fmt.Sprintf("%s.operator-ns.svc.cluster.local", shortName),
Type: corev1.ServiceTypeExternalName,
Selector: nil,
},
}
expectEqual(t, fc, want)
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o = stsOpts{
name: shortName,
secretName: fullName,
tailnetTargetFQDN: tailnetTargetFQDN,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
// Change the tailscale-target-fqdn annotation which should update the
// StatefulSet
tailnetTargetFQDN = "bar.baz.ts.net"
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.ObjectMeta.Annotations = map[string]string{
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
}
})
// Remove the tailscale-target-fqdn annotation which should make the
// operator clean up
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.ObjectMeta.Annotations = map[string]string{}
})
expectReconciled(t, sr, "default", "test")
// // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet
// // didn't create any child resources since this is all faked, so the
// // deletion goes through immediately.
expectReconciled(t, sr, "default", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
// // The deletion triggers another reconcile, to finish the cleanup.
expectReconciled(t, sr, "default", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
}
func TestTailnetTargetIPAnnotation(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
@@ -202,7 +320,13 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedEgressSTS(shortName, fullName, tailnetTargetIP, "default-test", ""))
o := stsOpts{
name: shortName,
secretName: fullName,
tailnetTargetIP: tailnetTargetIP,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -226,7 +350,13 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
expectEqual(t, fc, want)
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedEgressSTS(shortName, fullName, tailnetTargetIP, "default-test", ""))
o = stsOpts{
name: shortName,
secretName: fullName,
tailnetTargetIP: tailnetTargetIP,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
// Change the tailscale-target-ip annotation which should update the
// StatefulSet
@@ -254,10 +384,6 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
// At the moment we don't revert changes to the user created Service -
// we don't have a reliable way how to tell what it was before and also
// we don't really expect it to be re-used
}
func TestAnnotations(t *testing.T) {
@@ -305,7 +431,12 @@ func TestAnnotations(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -405,7 +536,12 @@ func TestAnnotationIntoLB(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, since it would have normally happened at
@@ -450,7 +586,12 @@ func TestAnnotationIntoLB(t *testing.T) {
expectReconciled(t, sr, "default", "test")
// None of the proxy machinery should have changed...
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
o = stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
// ... but the service should have a LoadBalancer status.
want = &corev1.Service{
@@ -528,7 +669,12 @@ func TestLBIntoAnnotation(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
@@ -591,7 +737,12 @@ func TestLBIntoAnnotation(t *testing.T) {
expectReconciled(t, sr, "default", "test")
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
o = stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
@@ -661,7 +812,12 @@ func TestCustomHostname(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "reindeer-flotilla", ""))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "reindeer-flotilla",
}
expectEqual(t, fc, expectedSTS(o))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -735,7 +891,7 @@ func TestCustomPriorityClassName(t *testing.T) {
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
proxyPriorityClassName: "tailscale-critical",
proxyPriorityClassName: "custom-priority-class-name",
},
logger: zl.Sugar(),
}
@@ -752,7 +908,7 @@ func TestCustomPriorityClassName(t *testing.T) {
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/expose": "true",
"tailscale.com/hostname": "custom-priority-class-name",
"tailscale.com/hostname": "tailscale-critical",
},
},
Spec: corev1.ServiceSpec{
@@ -764,8 +920,14 @@ func TestCustomPriorityClassName(t *testing.T) {
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "tailscale-critical",
priorityClassName: "custom-priority-class-name",
}
expectEqual(t, fc, expectedSTS(shortName, fullName, "custom-priority-class-name", "tailscale-critical"))
expectEqual(t, fc, expectedSTS(o))
}
func TestDefaultLoadBalancer(t *testing.T) {
@@ -811,7 +973,63 @@ func TestDefaultLoadBalancer(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
}
func TestProxyFirewallMode(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
tsFirewallMode: "nftables",
},
logger: zl.Sugar(),
isDefaultLoadBalancer: true,
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeLoadBalancer,
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
firewallMode: "nftables",
}
expectEqual(t, fc, expectedSTS(o))
}
func expectedSecret(name string) *corev1.Secret {
@@ -862,83 +1080,51 @@ func expectedHeadlessService(name string) *corev1.Service {
}
}
func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
APIVersion: "apps/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: stsName,
Namespace: "operator-ns",
Labels: map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource": "test",
"tailscale.com/parent-resource-ns": "default",
"tailscale.com/parent-resource-type": "svc",
},
},
Spec: appsv1.StatefulSetSpec{
Replicas: ptr.To[int32](1),
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "1234-UID"},
},
ServiceName: stsName,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
"tailscale.com/operator-last-set-hostname": hostname,
"tailscale.com/operator-last-set-cluster-ip": "10.20.30.40",
},
DeletionGracePeriodSeconds: ptr.To[int64](10),
Labels: map[string]string{"app": "1234-UID"},
},
Spec: corev1.PodSpec{
ServiceAccountName: "proxies",
PriorityClassName: priorityClassName,
InitContainers: []corev1.Container{
{
Name: "sysctler",
Image: "busybox",
Command: []string{"/bin/sh"},
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
SecurityContext: &corev1.SecurityContext{
Privileged: ptr.To(true),
},
},
},
Containers: []corev1.Container{
{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: secretName},
{Name: "TS_HOSTNAME", Value: hostname},
{Name: "TS_DEST_IP", Value: "10.20.30.40"},
},
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{"NET_ADMIN"},
},
},
ImagePullPolicy: "Always",
},
},
},
},
},
func expectedSTS(opts stsOpts) *appsv1.StatefulSet {
containerEnv := []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "TS_HOSTNAME", Value: opts.hostname},
}
annots := map[string]string{
"tailscale.com/operator-last-set-hostname": opts.hostname,
}
if opts.tailnetTargetIP != "" {
annots["tailscale.com/operator-last-set-ts-tailnet-target-ip"] = opts.tailnetTargetIP
containerEnv = append(containerEnv, corev1.EnvVar{
Name: "TS_TAILNET_TARGET_IP",
Value: opts.tailnetTargetIP,
})
} else if opts.tailnetTargetFQDN != "" {
annots["tailscale.com/operator-last-set-ts-tailnet-target-fqdn"] = opts.tailnetTargetFQDN
containerEnv = append(containerEnv, corev1.EnvVar{
Name: "TS_TAILNET_TARGET_FQDN",
Value: opts.tailnetTargetFQDN,
})
} else {
containerEnv = append(containerEnv, corev1.EnvVar{
Name: "TS_DEST_IP",
Value: "10.20.30.40",
})
annots["tailscale.com/operator-last-set-cluster-ip"] = "10.20.30.40"
}
if opts.firewallMode != "" {
containerEnv = append(containerEnv, corev1.EnvVar{
Name: "TS_DEBUG_FIREWALL_MODE",
Value: opts.firewallMode,
})
}
}
func expectedEgressSTS(stsName, secretName, tailnetTargetIP, hostname, priorityClassName string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
APIVersion: "apps/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: stsName,
Name: opts.name,
Namespace: "operator-ns",
Labels: map[string]string{
"tailscale.com/managed": "true",
@@ -952,23 +1138,20 @@ func expectedEgressSTS(stsName, secretName, tailnetTargetIP, hostname, priorityC
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "1234-UID"},
},
ServiceName: stsName,
ServiceName: opts.name,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
"tailscale.com/operator-last-set-hostname": hostname,
"tailscale.com/operator-last-set-ts-tailnet-target-ip": tailnetTargetIP,
},
Annotations: annots,
DeletionGracePeriodSeconds: ptr.To[int64](10),
Labels: map[string]string{"app": "1234-UID"},
},
Spec: corev1.PodSpec{
ServiceAccountName: "proxies",
PriorityClassName: priorityClassName,
PriorityClassName: opts.priorityClassName,
InitContainers: []corev1.Container{
{
Name: "sysctler",
Image: "busybox",
Image: "tailscale/tailscale",
Command: []string{"/bin/sh"},
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
SecurityContext: &corev1.SecurityContext{
@@ -980,13 +1163,7 @@ func expectedEgressSTS(stsName, secretName, tailnetTargetIP, hostname, priorityC
{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: secretName},
{Name: "TS_HOSTNAME", Value: hostname},
{Name: "TS_TAILNET_TARGET_IP", Value: tailnetTargetIP},
},
Env: containerEnv,
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{"NET_ADMIN"},
@@ -1126,6 +1303,16 @@ func expectRequeue(t *testing.T, sr *ServiceReconciler, ns, name string) {
}
}
type stsOpts struct {
name string
secretName string
hostname string
priorityClassName string
firewallMode string
tailnetTargetIP string
tailnetTargetFQDN string
}
type fakeTSClient struct {
sync.Mutex
keyRequests []tailscale.KeyCapabilities
@@ -1162,3 +1349,30 @@ func (c *fakeTSClient) Deleted() []string {
defer c.Unlock()
return c.deleted
}
func Test_isMagicDNSName(t *testing.T) {
tests := []struct {
in string
want bool
}{
{
in: "foo.tail4567.ts.net",
want: true,
},
{
in: "foo.tail4567.ts.net.",
want: true,
},
{
in: "foo.tail4567",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := isMagicDNSName(tt.in); got != tt.want {
t.Errorf("isMagicDNSName(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}

View File

@@ -21,10 +21,8 @@ import (
"k8s.io/client-go/transport"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/hostinfo"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
@@ -84,13 +82,14 @@ func parseAPIProxyMode() apiServerProxyMode {
// maybeLaunchAPIServerProxy launches the auth proxy, which is a small HTTP server
// that authenticates requests using the Tailscale LocalAPI and then proxies
// them to the kube-apiserver.
func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server) {
mode := parseAPIProxyMode()
func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server, mode apiServerProxyMode) {
if mode == apiserverProxyModeDisabled {
return
}
hostinfo.SetApp("k8s-operator-proxy")
startlog := zlog.Named("launchAPIProxy")
if mode == apiserverProxyModeNoAuth {
restConfig = rest.AnonymousClientConfig(restConfig)
}
cfg, err := restConfig.TransportConfig()
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
@@ -109,21 +108,21 @@ func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config,
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy").Infof, mode)
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode)
}
// apiserverProxy is an http.Handler that authenticates requests using the Tailscale
// LocalAPI and then proxies them to the Kubernetes API.
type apiserverProxy struct {
logf logger.Logf
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
log *zap.SugaredLogger
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
}
func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
who, err := h.lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
h.logf("failed to authenticate caller: %v", err)
h.log.Errorf("failed to authenticate caller: %v", err)
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
return
}
@@ -145,7 +144,7 @@ func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// are passed through to the Kubernetes API.
//
// It never returns.
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf, mode apiServerProxyMode) {
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode) {
if mode == apiserverProxyModeDisabled {
return
}
@@ -163,13 +162,14 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
log.Fatalf("could not get local client: %v", err)
}
ap := &apiserverProxy{
logf: logf,
lc: lc,
log: log,
lc: lc,
rp: &httputil.ReverseProxy{
Director: func(r *http.Request) {
Rewrite: func(r *httputil.ProxyRequest) {
// Replace the URL with the Kubernetes APIServer.
r.URL.Scheme = u.Scheme
r.URL.Host = u.Host
r.Out.URL.Scheme = u.Scheme
r.Out.URL.Host = u.Host
if mode == apiserverProxyModeNoAuth {
// If we are not providing authentication, then we are just
// proxying to the Kubernetes API, so we don't need to do
@@ -184,18 +184,18 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
// Out of paranoia, remove all authentication headers that might
// have been set by the client.
r.Header.Del("Authorization")
r.Header.Del("Impersonate-Group")
r.Header.Del("Impersonate-User")
r.Header.Del("Impersonate-Uid")
for k := range r.Header {
r.Out.Header.Del("Authorization")
r.Out.Header.Del("Impersonate-Group")
r.Out.Header.Del("Impersonate-User")
r.Out.Header.Del("Impersonate-Uid")
for k := range r.Out.Header {
if strings.HasPrefix(k, "Impersonate-Extra-") {
r.Header.Del(k)
r.Out.Header.Del(k)
}
}
// Now add the impersonation headers that we want.
if err := addImpersonationHeaders(r); err != nil {
if err := addImpersonationHeaders(r.Out, log); err != nil {
panic("failed to add impersonation headers: " + err.Error())
}
},
@@ -212,6 +212,7 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
Handler: ap,
}
log.Infof("listening on %s", ln.Addr())
if err := hs.ServeTLS(ln, "", ""); err != nil {
log.Fatalf("runAPIServerProxy: failed to serve %v", err)
}
@@ -234,7 +235,8 @@ type impersonateRule struct {
// addImpersonationHeaders adds the appropriate headers to r to impersonate the
// caller when proxying to the Kubernetes API. It uses the WhoIsResponse stashed
// in the context by the apiserverProxy.
func addImpersonationHeaders(r *http.Request) error {
func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
log = log.With("remote", r.RemoteAddr)
who := whoIsFromRequest(r)
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, capabilityName)
if err != nil {
@@ -252,21 +254,26 @@ func addImpersonationHeaders(r *http.Request) error {
}
r.Header.Add("Impersonate-Group", group)
groupsAdded.Add(group)
log.Debugf("adding group impersonation header for user group %s", group)
}
}
if !who.Node.IsTagged() {
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
log.Debugf("adding user impersonation header for user %s", who.UserProfile.LoginName)
return nil
}
// "Impersonate-Group" requires "Impersonate-User" to be set, so we set it
// to the node FQDN for tagged nodes.
r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, "."))
nodeName := strings.TrimSuffix(who.Node.Name, ".")
r.Header.Set("Impersonate-User", nodeName)
log.Debugf("adding user impersonation header for node name %s", nodeName)
// For legacy behavior (before caps), set the groups to the nodes tags.
if groupsAdded.Slice().Len() == 0 {
for _, tag := range who.Node.Tags {
r.Header.Add("Impersonate-Group", tag)
log.Debugf("adding group impersonation header for node tag %s", tag)
}
}
return nil

View File

@@ -10,12 +10,17 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"go.uber.org/zap"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
"tailscale.com/util/must"
)
func TestImpersonationHeaders(t *testing.T) {
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
emailish string
@@ -100,7 +105,7 @@ func TestImpersonationHeaders(t *testing.T) {
},
CapMap: tc.capMap,
})
addImpersonationHeaders(r)
addImpersonationHeaders(r, zl.Sugar())
if d := cmp.Diff(tc.wantHeaders, r.Header); d != "" {
t.Errorf("unexpected header (-want +got):\n%s", d)

View File

@@ -9,7 +9,9 @@ import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
@@ -19,6 +21,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apiserver/pkg/storage/names"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
"tailscale.com/client/tailscale"
@@ -44,15 +47,18 @@ const (
AnnotationHostname = "tailscale.com/hostname"
annotationTailnetTargetIPOld = "tailscale.com/ts-tailnet-target-ip"
AnnotationTailnetTargetIP = "tailscale.com/tailnet-ip"
//MagicDNS name of tailnet node.
AnnotationTailnetTargetFQDN = "tailscale.com/tailnet-fqdn"
// Annotations settable by users on ingresses.
AnnotationFunnel = "tailscale.com/funnel"
// Annotations set by the operator on pods to trigger restarts when the
// hostname or IP changes.
podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip"
podAnnotationLastSetHostname = "tailscale.com/operator-last-set-hostname"
podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip"
// hostname, IP or FQDN changes.
podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip"
podAnnotationLastSetHostname = "tailscale.com/operator-last-set-hostname"
podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip"
podAnnotationLastSetTailnetTargetFQDN = "tailscale.com/operator-last-set-ts-tailnet-target-fqdn"
)
type tailscaleSTSConfig struct {
@@ -67,6 +73,9 @@ type tailscaleSTSConfig struct {
// Tailscale IP of a Tailscale service we are setting up egress for
TailnetTargetIP string
// Tailscale FQDN of a Tailscale service we are setting up egress for
TailnetTargetFQDN string
Hostname string
Tags []string // if empty, use defaultTags
}
@@ -79,6 +88,14 @@ type tailscaleSTSReconciler struct {
operatorNamespace string
proxyImage string
proxyPriorityClassName string
tsFirewallMode string
}
func (sts tailscaleSTSReconciler) validate() error {
if sts.tsFirewallMode != "" && !isValidFirewallMode(sts.tsFirewallMode) {
return fmt.Errorf("invalid proxy firewall mode %s, valid modes are iptables, nftables or unset", sts.tsFirewallMode)
}
return nil
}
// IsHTTPSEnabledOnTailnet reports whether HTTPS is enabled on the tailnet.
@@ -141,10 +158,16 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.Sugare
return false, fmt.Errorf("getting device info: %w", err)
}
if id != "" {
// TODO: handle case where the device is already deleted, but the secret
// is still around.
logger.Debugf("deleting device %s from control", string(id))
if err := a.tsClient.DeleteDevice(ctx, string(id)); err != nil {
return false, fmt.Errorf("deleting device: %w", err)
errResp := &tailscale.ErrResponse{}
if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound {
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id))
} else {
return false, fmt.Errorf("deleting device: %w", err)
}
} else {
logger.Debugf("device %s deleted from control", string(id))
}
}
@@ -160,10 +183,39 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.Sugare
return true, nil
}
// maxStatefulSetNameLength is maximum length the StatefulSet name can
// have to NOT result in a too long value for controller-revision-hash
// label value (see https://github.com/kubernetes/kubernetes/issues/64023).
// controller-revision-hash label value consists of StatefulSet's name + hyphen + revision hash.
// Maximum label value length is 63 chars. Length of revision hash is 10 chars.
// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/pkg/controller/history/controller_history.go#L90-L104
const maxStatefulSetNameLength = 63 - 10 - 1
// statefulSetNameBase accepts name of parent resource and returns a string in
// form ts-<portion-of-parentname>- that, when passed to Kubernetes name
// generation will NOT result in a StatefulSet name longer than 52 chars.
// This is done because of https://github.com/kubernetes/kubernetes/issues/64023.
func statefulSetNameBase(parent string) string {
base := fmt.Sprintf("ts-%s-", parent)
// Calculate what length name GenerateName returns for this base.
generator := names.SimpleNameGenerator
generatedName := generator.GenerateName(base)
if excess := len(generatedName) - maxStatefulSetNameLength; excess > 0 {
base = base[:len(base)-excess-1] // take extra char off to make space for hyphen
base = base + "-" // re-instate hyphen
}
return base
}
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
nameBase := statefulSetNameBase(sts.ParentResourceName)
hsvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "ts-" + sts.ParentResourceName + "-",
GenerateName: nameBase,
Namespace: a.operatorNamespace,
Labels: sts.ChildResourceLabels,
},
@@ -291,10 +343,10 @@ func (a *tailscaleSTSReconciler) newAuthKey(ctx context.Context, tags []string)
return key, nil
}
//go:embed manifests/proxy.yaml
//go:embed deploy/manifests/proxy.yaml
var proxyYaml []byte
//go:embed manifests/userspace-proxy.yaml
//go:embed deploy/manifests/userspace-proxy.yaml
var userspaceProxyYaml []byte
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, authKeySecret string) (*appsv1.StatefulSet, error) {
@@ -307,6 +359,13 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
if err := yaml.Unmarshal(proxyYaml, &ss); err != nil {
return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err)
}
for i := range ss.Spec.Template.Spec.InitContainers {
c := &ss.Spec.Template.Spec.InitContainers[i]
if c.Name == "sysctler" {
c.Image = a.proxyImage
break
}
}
}
container := &ss.Spec.Template.Spec.Containers[0]
container.Image = a.proxyImage
@@ -329,7 +388,11 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Name: "TS_TAILNET_TARGET_IP",
Value: sts.TailnetTargetIP,
})
} else if sts.TailnetTargetFQDN != "" {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_TAILNET_TARGET_FQDN",
Value: sts.TailnetTargetFQDN,
})
} else if sts.ServeConfig != nil {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_SERVE_CONFIG",
@@ -353,6 +416,13 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
},
})
}
if a.tsFirewallMode != "" {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_DEBUG_FIREWALL_MODE",
Value: a.tsFirewallMode,
},
)
}
ss.ObjectMeta = metav1.ObjectMeta{
Name: headlessSvc.Name,
Namespace: a.operatorNamespace,
@@ -378,6 +448,9 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
if sts.TailnetTargetIP != "" {
ss.Spec.Template.Annotations[podAnnotationLastSetTailnetTargetIP] = sts.TailnetTargetIP
}
if sts.TailnetTargetFQDN != "" {
ss.Spec.Template.Annotations[podAnnotationLastSetTailnetTargetFQDN] = sts.TailnetTargetFQDN
}
ss.Spec.Template.Labels = map[string]string{
"app": sts.ParentResourceUID,
}
@@ -492,3 +565,7 @@ func nameForService(svc *corev1.Service) (string, error) {
}
return svc.Namespace + "-" + svc.Name, nil
}
func isValidFirewallMode(m string) bool {
return m == "auto" || m == "nftables" || m == "iptables"
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"testing"
)
// Test_statefulSetNameBase tests that parent name portion in a StatefulSet name
// base will be truncated if the parent name is longer than 43 chars to ensure
// that the total does not exceed 52 chars.
// How many chars need to be cut off parent name depends on an internal var in
// kube name generation code that can change at which point this test will break
// and need to be changed. This is okay as we do not rely on that value in
// code whilst being aware when it changes might still be useful.
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/staging/src/k8s.io/apiserver/pkg/storage/names/generate.go#L45.
// https://github.com/kubernetes/kubernetes/pull/116430
func Test_statefulSetNameBase(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{
name: "43 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "44 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xbo",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "42 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x-",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := statefulSetNameBase(tt.in); got != tt.out {
t.Errorf("stsNamePrefix(%s) = %q, want %s", tt.in, got, tt.out)
}
})
}
}

View File

@@ -17,6 +17,7 @@ import (
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/util/clientmetric"
@@ -37,6 +38,8 @@ type ServiceReconciler struct {
// managedEgressProxies is a set of all egress proxies that we're currently
// managing. This is only used for metrics.
managedEgressProxies set.Slice[types.UID]
recorder record.EventRecorder
}
var (
@@ -78,7 +81,8 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
}
targetIP := a.tailnetTargetAnnotation(svc)
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && targetIP == "" {
targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && targetIP == "" && targetFQDN == "" {
logger.Debugf("service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc)
}
@@ -136,6 +140,21 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
// This function adds a finalizer to svc, ensuring that we can handle orderly
// deprovisioning later.
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
// Run for proxy config related validations here as opposed to running
// them earlier. This is to prevent cleanup being blocked on a
// misconfigured proxy param.
if err := a.ssr.validate(); err != nil {
msg := fmt.Sprintf("unable to provision proxy resources: invalid config: %v", err)
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDCONFIG", msg)
a.logger.Error(msg)
return nil
}
if violations := validateService(svc); len(violations) > 0 {
msg := fmt.Sprintf("unable to provision proxy resources: invalid Service: %s", strings.Join(violations, ", "))
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVCICE", msg)
a.logger.Error(msg)
return nil
}
hostname, err := nameForService(svc)
if err != nil {
return err
@@ -175,6 +194,14 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
sts.TailnetTargetIP = ip
a.managedEgressProxies.Add(svc.UID)
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
} else if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
sts.TailnetTargetFQDN = fqdn
a.managedEgressProxies.Add(svc.UID)
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
}
a.mu.Unlock()
@@ -183,7 +210,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
return fmt.Errorf("failed to provision: %w", err)
}
if sts.TailnetTargetIP != "" {
if sts.TailnetTargetIP != "" || sts.TailnetTargetFQDN != "" {
// TODO (irbekrm): cluster.local is the default DNS name, but
// can be changed by users. Make this configurable or figure out
// how to discover the DNS name from within operator
@@ -242,6 +269,19 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
return nil
}
func validateService(svc *corev1.Service) []string {
violations := make([]string, 0)
if svc.Annotations[AnnotationTailnetTargetFQDN] != "" && svc.Annotations[AnnotationTailnetTargetIP] != "" {
violations = append(violations, "only one of annotations %s and %s can be set", AnnotationTailnetTargetIP, AnnotationTailnetTargetFQDN)
}
if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
if !isMagicDNSName(fqdn) {
violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q does not appear to be a valid MagicDNS name", AnnotationTailnetTargetFQDN, fqdn))
}
}
return violations
}
func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool {
// Headless services can't be exposed, since there is no ClusterIP to
// forward to.

104
cmd/sniproxy/handlers.go Normal file
View File

@@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"fmt"
"log"
"math/rand"
"net"
"net/netip"
"slices"
"inet.af/tcpproxy"
"tailscale.com/net/netutil"
)
type tcpRoundRobinHandler struct {
// To is a list of destination addresses to forward to.
// An entry may be either an IP address or a DNS name.
To []string
// DialContext is used to make the outgoing TCP connection.
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
// ReachableIPs enumerates the IP addresses this handler is reachable on.
ReachableIPs []netip.Addr
}
// ReachableOn returns the IP addresses this handler is reachable on.
func (h *tcpRoundRobinHandler) ReachableOn() []netip.Addr {
return h.ReachableIPs
}
func (h *tcpRoundRobinHandler) Handle(c net.Conn) {
addrPortStr := c.LocalAddr().String()
_, port, err := net.SplitHostPort(addrPortStr)
if err != nil {
log.Printf("tcpRoundRobinHandler.Handle: bogus addrPort %q", addrPortStr)
c.Close()
return
}
var p tcpproxy.Proxy
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
return netutil.NewOneConnListener(c, nil), nil
}
dest := h.To[rand.Intn(len(h.To))]
dial := &tcpproxy.DialProxy{
Addr: fmt.Sprintf("%s:%s", dest, port),
DialContext: h.DialContext,
}
p.AddRoute(addrPortStr, dial)
p.Start()
}
type tcpSNIHandler struct {
// Allowlist enumerates the FQDNs which may be proxied via SNI. An
// empty slice means all domains are permitted.
Allowlist []string
// DialContext is used to make the outgoing TCP connection.
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
// ReachableIPs enumerates the IP addresses this handler is reachable on.
ReachableIPs []netip.Addr
}
// ReachableOn returns the IP addresses this handler is reachable on.
func (h *tcpSNIHandler) ReachableOn() []netip.Addr {
return h.ReachableIPs
}
func (h *tcpSNIHandler) Handle(c net.Conn) {
addrPortStr := c.LocalAddr().String()
_, port, err := net.SplitHostPort(addrPortStr)
if err != nil {
log.Printf("tcpSNIHandler.Handle: bogus addrPort %q", addrPortStr)
c.Close()
return
}
var p tcpproxy.Proxy
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
return netutil.NewOneConnListener(c, nil), nil
}
p.AddSNIRouteFunc(addrPortStr, func(ctx context.Context, sniName string) (t tcpproxy.Target, ok bool) {
if len(h.Allowlist) > 0 {
// TODO(tom): handle subdomains
if slices.Index(h.Allowlist, sniName) < 0 {
return nil, false
}
}
return &tcpproxy.DialProxy{
Addr: net.JoinHostPort(sniName, port),
DialContext: h.DialContext,
}, true
})
p.Start()
}

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