Compare commits

...

492 Commits

Author SHA1 Message Date
Marwan Sulaiman
df49cb24d5 ipn, ipn/ipnlocal: add an in memory serve config
This PR adds a parallel in-memory ServeConfig so that foreground
funnels are guaranteed to go away in case of unexpected shutdown

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-08-24 15:38:54 +01:00
Marwan Sulaiman
9c07f4f512 all: replace deprecated ioutil references
This PR removes calls to ioutil library and replaces them
with their new locations in the io and os packages.

Fixes #9034
Updates #5210

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-08-23 23:53:19 +01:00
Denton Gentry
1b8a538953 scripts/installer.sh: add CloudLinux and Alibaba Linux
Fixes https://github.com/tailscale/tailscale/issues/9010

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-23 15:29:17 -07:00
Sonia Appasamy
776f9b5875 client/web: open auth URLs in new browser tab
Open control server auth URLs in new browser tabs on web clients
so users don't loose original client URL when redirected for login.

Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-23 17:38:50 -04:00
Brad Fitzpatrick
ad9b711a1b tailcfg: bump capver to 72 to restore UPnP
Actually fixed in 77ff705545 but that was cherry-picked to a branch
and we don't bump capver in branches.

This tells the control plane that UPnP should be re-enabled going
forward.

Updates #8992

Change-Id: I5c4743eb52fdee94175668c368c0f712536dc26b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-23 13:55:39 -07:00
Brad Fitzpatrick
ea4425d8a9 ipn/ipnlocal, wgengine/magicsock: move UpdateStatus stuff around
Upcoming work on incremental netmap change handling will require some
replumbing of which subsystems get notified about what. Done naively,
it could break "tailscale status --json" visibility later. To make sure
I understood the flow of all the updates I was rereading the status code
and realized parts of ipnstate.Status were being populated by the wrong
subsystems.

The engine (wireguard) and magicsock (data plane, NAT traveral) should
only populate the stuff that they uniquely know. The WireGuard bits
were fine but magicsock was populating stuff stuff that LocalBackend
could've better handled, so move it there.

Updates #1909

Change-Id: I6d1b95d19a2d1b70fbb3c875fac8ea1e169e8cb0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-23 13:35:47 -07:00
Maisem Ali
74388a771f cmd/k8s-operator: fix regression from earlier refactor
I forgot to move the defer out of the func, so the tsnet.Server
immediately closed after starting.

Updates #502

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-23 15:14:29 -04:00
Brad Fitzpatrick
9089efea06 net/netmon: make ChangeFunc's signature take new ChangeDelta, not bool
Updates #9040

Change-Id: Ia43752064a1a6ecefc8802b58d6eaa0b71cf1f84
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-23 10:42:14 -07:00
Sonia Appasamy
78f087aa02 cli/web: pass existing localClient to web client
Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-23 13:25:11 -04:00
David Anderson
5cfa85e604 tsweb: clean up pprof handler registration, document why it's there
Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-08-23 10:16:14 -07:00
Will Norris
09068f6c16 release: add empty embed.FS for release files
This ensures that `go mod vendor` includes these files, which are needed
for client builds run in corp.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-23 09:54:10 -07:00
Maisem Ali
836f932ead cmd/k8s-operator: split operator.go into svc.go/sts.go
Updates #502

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-23 12:07:07 -04:00
Maisem Ali
7f6bc52b78 cmd/k8s-operator: refactor operator code
It was jumbled doing a lot of things, this breaks it up into
the svc reconciliation and the tailscale sts reconciliation.

Prep for future commit.

Updates #502

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-23 12:07:07 -04:00
Will Norris
cf45d6a275 client/web: remove old /redirect handler
I thought this had something to do with Synology or QNAP support, since
they both have specific authentication logic.  But it turns out this was
part of the original web client added in #1621, and then refactored as
part of #2093.  But with how we handle logging in now, it's never
called.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-22 16:39:30 -07:00
Andrew Lytvynov
05523bdcdd release/dist/cli: add gen-key command (#9023)
Add a new subcommand to generate a Ed25519 key pair for release signing.
The same command can be used to generate both root and signing keys.

Updates #8760

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-22 16:29:56 -07:00
James Tucker
e1c7e9b736 wgengine/magicsock: improve endpoint selection for WireGuard peers with rx time
If we don't have the ICMP hint available, such as on Android, we can use
the signal of rx traffic to bias toward a particular endpoint.

We don't want to stick to a particular endpoint for a very long time
without any signals, so the sticky time is reduced to 1 second, which is
large enough to avoid excessive packet reordering in the common case,
but should be small enough that either rx provides a strong signal, or
we rotate in a user-interactive schedule to another endpoint, improving
the feel of failover to other endpoints.

Updates #8999

Co-authored-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>

Signed-off-by: James Tucker <james@tailscale.com>
Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-08-22 15:39:08 -07:00
James Tucker
5edb39d032 wgengine/magicsock: clear out endpoint statistics when it becomes bad
There are cases where we do not detect the non-viability of a route, but
we will instead observe a failure to send. In a Disco path this would
normally be handled as a side effect of Disco, which is not available to
non-Disco WireGuard nodes. In both cases, recognizing the failure as
such will result in faster convergence.

Updates #8999
Signed-off-by: James Tucker <james@tailscale.com>
2023-08-22 15:22:50 -07:00
Charlotte Brandhorst-Satzkorn
7c9c68feed wgengine/magicsock: update lastfullping comment to include wg only
LastFullPing is now used for disco or wireguard only endpoints. This
change updates the comment to make that clear.

Updates #7826

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-08-22 14:31:19 -07:00
Aaron Klotz
ea693eacb6 util/winutil: add RegisterForRestart, allowing programs to indicate their preferences to the Windows restart manager
In order for the installer to restart the GUI correctly post-upgrade, we
need the GUI to be able to register its restart preferences.

This PR adds API support for doing so. I'm adding it to OSS so that it
is available should we need to do any such registrations on OSS binaries
in the future.

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

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-08-22 15:06:48 -06:00
James Tucker
3a652d7761 wgengine/magicsock: clear endpoint state in noteConnectivityChange
There are latency values stored in bestAddr and endpointState that are
no longer applicable after a connectivity change and should be cleared
out, following the documented behavior of the function.

Updates #8999

Signed-off-by: James Tucker <james@tailscale.com>
2023-08-22 13:38:20 -07:00
Andrew Lytvynov
7364c6beec clientupdate/distsign: add new library for package signing/verification (#8943)
This library is intended for use during release to sign packages which
are then served from pkgs.tailscale.com.
The library is also then used by clients downloading packages for
`tailscale update` where OS package managers / app stores aren't used.

Updates https://github.com/tailscale/tailscale/issues/8760
Updates https://github.com/tailscale/tailscale/issues/6995

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-22 13:35:30 -07:00
Maisem Ali
4b13e6e087 go.mod: bump golang.org/x/net
Theory is that our long lived http2 connection to control would
get tainted by _something_ (unclear what) and would get closed.

This picks up the fix for golang/go#60818.

Updates tailscale/corp#5761

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-22 16:25:19 -04:00
Will Norris
5ebff95a4c client/web: fix globbing for file embedding
src/**/* was only grabbing files in subdirectories, but not in the src
directory itself.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-22 12:42:34 -07:00
Marwan Sulaiman
000c0a70f6 ipn, ipn/ipnlocal: clean up documentation and use clock instead of time
This PR addresses a number of the follow ups from PR #8491 that were written
after getting merged.

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-08-22 19:17:29 +01:00
Will Norris
0df5507c81 client/web: combine embeds into a single embed.FS
instead of embedding each file individually, embed them all into a
single embed filesystem.  This is basically a noop for the current
frontend, but sets things up a little cleaner for the new frontend.

Also added an embed.FS for the source files needed to build the new
frontend. These files are not actually embedded into the binary (since
it is a blank identifier), but causes `go mod vendor` to copy them into
the vendor directory.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-22 11:17:16 -07:00
Will Norris
3722b05465 release/dist: run yarn build before building CLI
This builds the assets for the new web client as part of our release
process. The path to the web client source is specified by the
-web-client-root flag.  This allows corp builds to first vendor the
tailscale.com module, and then build the web client assets in the vendor
directory.

The default value for the -web-client-root flag is empty, so no assets
are built by default.

This is an update of the previously reverted 0fb95ec

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-22 11:12:47 -07:00
Sonia Appasamy
09e5e68297 client/web: track web client initializations
Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-22 14:11:19 -04:00
Brad Fitzpatrick
947def7688 types/netmap: remove redundant Netmap.Hostinfo
It was in SelfNode.Hostinfo anyway. The redundant copy was just
costing us an allocation per netmap (a Hostinfo.Clone).

Updates #1909

Change-Id: Ifac568aa5f8054d9419828489442a0f4559bc099
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-22 09:54:02 -07:00
Sonia Appasamy
50b558de74 client/web: hook up remaining legacy POST requests
Hooks up remaining legacy POST request from the React side in --dev.

Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-22 12:42:12 -04:00
Brad Fitzpatrick
db017d3b12 control/controlclient: remove quadratic allocs in mapSession
The mapSession code was previously quadratic: N clients in a netmap
send updates proportional to N and then for each, we do N units of
work. This removes most of that "N units of work" per update. There's
still a netmap-sized slice allocation per update (that's #8963), but
that's it.

Bit more efficient now, especially with larger netmaps:

                                 │     before     │                after                │
                                 │     sec/op     │   sec/op     vs base                │
    MapSessionDelta/size_10-8       47.935µ ±  3%   1.232µ ± 2%  -97.43% (p=0.000 n=10)
    MapSessionDelta/size_100-8      79.950µ ±  3%   1.642µ ± 2%  -97.95% (p=0.000 n=10)
    MapSessionDelta/size_1000-8    355.747µ ± 10%   4.400µ ± 1%  -98.76% (p=0.000 n=10)
    MapSessionDelta/size_10000-8   3079.71µ ±  3%   27.89µ ± 3%  -99.09% (p=0.000 n=10)
    geomean                          254.6µ         3.969µ       -98.44%

                                 │     before     │                after                 │
                                 │      B/op      │     B/op      vs base                │
    MapSessionDelta/size_10-8        9.651Ki ± 0%   2.395Ki ± 0%  -75.19% (p=0.000 n=10)
    MapSessionDelta/size_100-8      83.097Ki ± 0%   3.192Ki ± 0%  -96.16% (p=0.000 n=10)
    MapSessionDelta/size_1000-8     800.25Ki ± 0%   10.32Ki ± 0%  -98.71% (p=0.000 n=10)
    MapSessionDelta/size_10000-8   7896.04Ki ± 0%   82.32Ki ± 0%  -98.96% (p=0.000 n=10)
    geomean                          266.8Ki        8.977Ki       -96.64%

                                 │    before     │               after                │
                                 │   allocs/op   │ allocs/op   vs base                │
    MapSessionDelta/size_10-8         72.00 ± 0%   20.00 ± 0%  -72.22% (p=0.000 n=10)
    MapSessionDelta/size_100-8       523.00 ± 0%   20.00 ± 0%  -96.18% (p=0.000 n=10)
    MapSessionDelta/size_1000-8     5024.00 ± 0%   20.00 ± 0%  -99.60% (p=0.000 n=10)
    MapSessionDelta/size_10000-8   50024.00 ± 0%   20.00 ± 0%  -99.96% (p=0.000 n=10)
    geomean                          1.754k        20.00       -98.86%

Updates #1909

Change-Id: I41ee29358a5521ed762216a76d4cc5b0d16e46ac
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-22 08:59:57 -07:00
shayne
a3b0654ed8 .github: add flakehub-publish-tagged.yml (#9009)
This workflow will publish a flake to flakehub when a tag is pushed to
the repository. It will only publish tags that match the pattern
`v*.*.*`.

Fixes #9008

Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
2023-08-22 11:18:29 -04:00
Marwan Sulaiman
35ff5bf5a6 cmd/tailscale/cli, ipn/ipnlocal: [funnel] add stream mode
Adds ability to start Funnel in the foreground and stream incoming
connections. When foreground process is stopped, Funnel is turned
back off for the port.

Exampe usage:
```
TAILSCALE_FUNNEL_V2=on tailscale funnel 8080
```

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-08-22 10:07:34 -04:00
Brad Fitzpatrick
cb4a61f951 control/controlclient: don't clone self node on each NetworkMap
Drop in the bucket, but have to start somewhere.

Real wins will come once this is done for peers.

                                 │     before     │                after                │
                                 │      B/op      │     B/op       vs base              │
    MapSessionDelta/size_10-8      10.213Ki ± ∞ ¹   9.650Ki ± ∞ ¹  -5.51% (p=0.008 n=5)
    MapSessionDelta/size_100-8      83.64Ki ± ∞ ¹   83.08Ki ± ∞ ¹  -0.67% (p=0.008 n=5)
    MapSessionDelta/size_1000-8     800.8Ki ± ∞ ¹   800.3Ki ± ∞ ¹  -0.07% (p=0.008 n=5)
    MapSessionDelta/size_10000-8    7.712Mi ± ∞ ¹   7.711Mi ± ∞ ¹  -0.01% (p=0.008 n=5)
    geomean                         271.1Ki         266.8Ki        -1.59%

                                 │    before    │               after                │
                                 │  allocs/op   │  allocs/op    vs base              │
    MapSessionDelta/size_10-8       73.00 ± ∞ ¹    72.00 ± ∞ ¹  -1.37% (p=0.008 n=5)
    MapSessionDelta/size_100-8      524.0 ± ∞ ¹    523.0 ± ∞ ¹  -0.19% (p=0.008 n=5)
    MapSessionDelta/size_1000-8    5.025k ± ∞ ¹   5.024k ± ∞ ¹  -0.02% (p=0.008 n=5)
    MapSessionDelta/size_10000-8   50.02k ± ∞ ¹   50.02k ± ∞ ¹  -0.00% (p=0.040 n=5)
    geomean                        1.761k         1.754k        -0.40%

Updates #1909

Change-Id: Ie19dea3371de251d64d4373dd00422f53c2675ea
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-21 15:42:33 -07:00
Will Norris
a461d230db Revert "release/dist: run yarn build before building CLI"
This caused breakages on the build server:

synology/dsm7/x86_64: chdir /home/ubuntu/builds/2023-08-21T21-47-38Z-unstable-main-tagged-devices/0/client/web: no such file or directory
synology/dsm7/i686: chdir /home/ubuntu/builds/2023-08-21T21-47-38Z-unstable-main-tagged-devices/0/client/web: no such file or directory
synology/dsm7/armv8: chdir /home/ubuntu/builds/2023-08-21T21-47-38Z-unstable-main-tagged-devices/0/client/web: no such file or directory
...

Reverting while I investigate.

This reverts commit 0fb95ec07d.

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-21 14:56:05 -07:00
Will Norris
0fb95ec07d release/dist: run yarn build before building CLI
This builds the assets for the new web client as part of our release
process. These assets will soon be embedded into the cmd/tailscale
binary, but are not actually done so yet.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-21 14:30:59 -07:00
Brad Fitzpatrick
84b94b3146 types/netmap, all: make NetworkMap.SelfNode a tailcfg.NodeView
Updates #1909

Change-Id: I8c470cbc147129a652c1d58eac9b790691b87606
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-21 13:34:49 -07:00
License Updater
699f9699ca licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-08-21 12:36:37 -07:00
Flakes Updater
f6615931d7 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-08-21 12:04:38 -07:00
Sonia Appasamy
077bbb8403 client/web: add csrf protection to web client api
Adds csrf protection and hooks up an initial POST request from
the React web client.

Updates tailscale/corp#13775

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-08-21 15:02:02 -04:00
Andrew Dunham
77ff705545 net/portmapper: never select port 0 in UPnP
Port 0 is interpreted, per the spec (but inconsistently among router
software) as requesting to map every single available port on the UPnP
gateway to the internal IP address. We'd previously avoided picking
ports below 1024 for one of the two UPnP methods (in #7457), and this
change moves that logic so that we avoid it in all cases.

Updates #8992

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I20d652c0cd47a24aef27f75c81f78ae53cc3c71e
2023-08-21 14:33:26 -04:00
Brad Fitzpatrick
b5ff68a968 control/controlclient: flesh out mapSession to break up gigantic method
Now mapSession has a bunch more fields and methods, rather than being
just one massive func with a ton of local variables.

So far there are no major new optimizations, though. It should behave
the same as before.

This has been done with an eye towards testability (so tests can set
all the callback funcs as needed, or not, without a huge Direct client
or long-running HTTP requests), but this change doesn't add new tests
yet. That will follow in the changes which flesh out the NetmapUpdater
interface.

Updates #1909

Change-Id: Iad4e7442d5bbbe2614bd4b1dc4b02e27504898df
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-21 10:38:32 -07:00
Brad Fitzpatrick
1b223566dd util/linuxfw: fix typo in unexported doc comment
And flesh it out and use idiomatic doc style ("whether" for bools)
and end in a period while there anyway.

Updates #cleanup

Change-Id: Ieb82f13969656e2340c3510e7b102dc8e6932611
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-21 10:14:28 -07:00
Val
c85d7c301a tool: force HTTP/1.1 in curl to prevent hang behind load balancer
When running in our github CI environment, curl sometimes hangs while closing
the download from the nodejs.org server and fails with INTERNAL_ERROR. This is
likely caused by CI running behind some kind of load balancer or proxy that
handles HTTP/2 incorrectly in some minor way, so force curl to use HTTP 1.1.

Updates #8988

Signed-off-by: Val <valerie@tailscale.com>
2023-08-21 08:37:26 -07:00
Denton Gentry
f486041fd1 tsnet: add support for clientmetrics.
Updates https://github.com/tailscale/tailscale/issues/1748

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-21 06:26:40 -07:00
Val
c15997511d wgengine/magicsock: only accept pong sent by CLI ping
When sending a ping from the CLI, only accept a pong that is in reply
to the specific CLI ping we sent.

Updates #311

Signed-off-by: Val <valerie@tailscale.com>
2023-08-21 01:57:41 -07:00
Brad Fitzpatrick
165f0116f1 types/netmap: move some mutations earlier, remove, document some fields
And optimize the Persist setting a bit, allocating later and only mutating
fields when there's been a Node change.

Updates #1909

Change-Id: Iaddfd9e88ef76e1d18e8d0a41926eb44d0955312
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-20 16:26:11 -07:00
Brad Fitzpatrick
21170fb175 control/controlclient: scope a variable tighter, de-pointer a *time.Time
Just misc cleanups.

Updates #1909

Change-Id: I9d64cb6c46d634eb5fdf725c13a6c5e514e02e9a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-20 15:06:24 -07:00
Maisem Ali
2548496cef types/views,cmd/viewer: add ByteSlice[T] to replace mem.RO
Add a new views.ByteSlice[T ~[]byte] to provide a better API to use
with views.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-20 15:30:35 -04:00
Maisem Ali
8a5ec72c85 cmd/cloner: use maps.Clone and ptr.To
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-20 13:47:26 -04:00
Brad Fitzpatrick
4511e7d64e ipn/ipnstate: add PeerStatus.AltSharerUserID, stop mangling Node.User
In b987b2ab18 (2021-01-12) when we introduced sharing we mapped
the sharer to the userid at a low layer, mostly to fix the display of
"tailscale status" and the client UIs, but also some tests.

The commit earlier today, 7dec09d169, removed the 2.5yo option
to let clients disable that automatic mapping, as clearly we were never
getting around to it.

This plumbs the Sharer UserID all the way to ipnstatus so the CLI
itself can choose to print out the Sharer's identity over the node's
original owner.

Then we stop mangling Node.User and let clients decide how they want
to render things.

To ease the migration for the Windows GUI (which currently operates on
tailcfg.Node via the NetMap from WatchIPNBus, instead of PeerStatus),
a new method Node.SharerOrUser is added to do the mapping of
Sharer-else-User.

Updates #1909
Updates tailscale/corp#1183

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-20 08:18:52 -07:00
Maisem Ali
d483ed7774 tailcfg: generate RegisterResponse.Clone, remove manually written
It had a custom Clone func with a TODO to replace with cloner, resolve
that todo. Had to pull out the embedded Auth struct into a named struct.

Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-19 23:35:57 -04:00
Brad Fitzpatrick
282dad1b62 tailcfg: update docs on NetInfo.FirewallMode
Updates #391

Change-Id: Ifef196b31dd145f424fb0c0d0bb04565cc22c717
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-19 20:19:33 -07:00
Brad Fitzpatrick
d8191a9813 ipn/ipnlocal: fix regression in printf arg type
I screwed this up in 58a4fd43d as I expected. I even looked out for
cases like this (because this always happens) and I still missed
it. Vet doesn't flag these because they're not the standard printf
funcs it knows about. TODO: make our vet recognize all our
"logger.Logf" types.

Updates #8948

Change-Id: Iae267d5f81da49d0876b91c0e6dc451bf7dcd721
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-19 20:03:11 -07:00
Brad Fitzpatrick
f35ff84ee2 util/deephash: relax an annoyingly needy test
I'd added a test case of deephash against a tailcfg.Node to make sure
it worked at all more than anything. We don't care what the exact
bytes are in this test, just that it doesn't fail. So adjust for that.

Then when we make changes to tailcfg.Node and types under it, we don't
need to keep adjusting this test.

Updates #cleanup

Change-Id: Ibf4fa42820aeab8f5292fe65f9f92ffdb0b4407b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-19 19:57:03 -07:00
Brad Fitzpatrick
93a806ba31 types/tkatype: add test for MarshaledSignature's JSON format
Lock in its wire format before a potential change to its Go type.

Updates #1909

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-19 19:34:18 -07:00
Brad Fitzpatrick
7dec09d169 control/controlclient: remove Opts.KeepSharerAndUserSplit
It was added 2.5 years ago in c1dabd9436 but was never used.
Clearly that migration didn't matter.

We can attempt this again later if/when this matters.

Meanwhile this simplifies the code and thus makes working on other
current efforts in these parts of the code easier.

Updates #1909

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-19 15:06:05 -07:00
Maisem Ali
02b47d123f tailcfg: remove unused Domain field from Login/User
Updates #cleanup

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-18 20:07:17 -07:00
Brad Fitzpatrick
58a4fd43d8 types/netmap, all: use read-only tailcfg.NodeView in NetworkMap
Updates #8948

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-18 20:04:35 -07:00
KevinLiang10
b040094b90 util/linuxfw: reorganize nftables rules to allow it to work with ufw
This commit tries to mimic the way iptables-nft work with the filewall rules. We
follow the convention of using tables like filter, nat and the conventional
chains, to make our nftables implementation work with ufw.

Updates: #391

Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-08-18 18:24:05 -07:00
Will Norris
d4586ca75f tsnet/example/web-client: listen on localhost
Serving the web client on the tailscale interface, while useful for
remote management, is also inherently risky if ACLs are not configured
appropriately. Switch the example to listen only on localhost, which is
a much safer default. This is still a valuable example, since it still
demonstrates how to have a web client connected to a tsnet instance.

Updates #13775

Signed-off-by: Will Norris <will@tailscale.com>
2023-08-18 14:57:08 -07:00
KevinLiang10
93cab56277 wgengine/router: fall back and set iptables as default again
Due to the conflict between our nftables implementation and ufw, which is a common utility used
on linux. We now want to take a step back to prevent regression. This will give us more chance to
let users to test our nftables support and heuristic.

Updates: #391
Signed-off-by: KevinLiang10 <kevinliang@tailscale.com>
2023-08-18 16:33:06 -04:00
Brad Fitzpatrick
6e57dee7eb cmd/viewer, types/views, all: un-special case slice of netip.Prefix
Make it just a views.Slice[netip.Prefix] instead of its own named type.

Having the special case led to circular dependencies in another WIP PR
of mine.

Updates #8948

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-18 12:27:44 -07:00
Brad Fitzpatrick
261cc498d3 types/views: add LenIter method to slice view types
This is basically https://github.com/bradfitz/iter which was
a joke but now that Go's adding range over int soonish, might
as well. It simplies our code elsewher that uses slice views.

Updates #8948

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-18 08:21:52 -07:00
Brad Fitzpatrick
af2e4909b6 all: remove some Debug fields, NetworkMap.Debug, Reconfig Debug arg
Updates #8923

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-17 19:04:30 -07:00
Andrew Lytvynov
86ad1ea60e clientupdate: parse /etc/synoinfo.conf to get CPU arch (#8940)
The hardware version in `/proc/sys/kernel/syno_hw_version` does not map
exactly to versions in
https://github.com/SynoCommunity/spksrc/wiki/Synology-and-SynoCommunity-Package-Architectures.
It contains some slightly different version formats.

Instead, `/etc/synoinfo.conf` exists and contains a `unique` line with
the CPU architecture encoded. Parse that out and filter through the list
of architectures that we have SPKs for.

Tested on DS218 and DS413j.

Updates #8927

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-08-17 16:45:50 -07:00
Marwan Sulaiman
72d2122cad cmd/tailscale: change serve and funnel calls to StatusWithoutPeers
The tailscale serve|funnel commands frequently call the LocalBackend's Status
but they never need the peers to be included. This PR changes the call to be
StatusWithoutPeers which should gain a noticeable speed improvement

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-08-17 17:01:43 -04:00
Brad Fitzpatrick
121d1d002c tailcfg: add nodeAttrs for forcing OneCGNAT on/off [capver 71]
Updates #8923

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-17 13:32:12 -07:00
Brad Fitzpatrick
25663b1307 tailcfg: remove most Debug fields, move bulk to nodeAttrs [capver 70]
Now a nodeAttr: ForceBackgroundSTUN, DERPRoute, TrimWGConfig,
DisableSubnetsIfPAC, DisableUPnP.

Kept support for, but also now a NodeAttr: RandomizeClientPort.

Removed: SetForceBackgroundSTUN, SetRandomizeClientPort (both never
used, sadly... never got around to them. But nodeAttrs are better
anyway), EnableSilentDisco (will be a nodeAttr later when that effort
resumes).

Updates #8923

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-17 10:52:47 -07:00
David Anderson
e92adfe5e4 net/art: allow non-pointers as values
Values are still turned into pointers internally to maintain the
invariants of strideTable, but from the user's perspective it's
now possible to tbl.Insert(pfx, true) rather than
tbl.Insert(pfx, ptr.To(true)).

Updates #7781

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-08-17 10:43:18 -07:00
Brad Fitzpatrick
bc0eb6b914 all: import x/exp/maps as xmaps to distinguish from Go 1.21 "maps"
Updates #8419

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-17 09:54:18 -07:00
Brad Fitzpatrick
e8551d6b40 all: use Go 1.21 slices, maps instead of x/exp/{slices,maps}
Updates #8419

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-17 08:42:35 -07:00
Denton Gentry
e8d140654a cmd/derper: count bootstrap dns unique lookups.
Updates https://github.com/tailscale/corp/issues/13979

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-17 08:02:56 -07:00
Denton Gentry
7e15c78a5a syncs: add map.Clear() method
Updates https://github.com/tailscale/corp/issues/13979

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-08-17 08:02:56 -07:00
Brad Fitzpatrick
239ad57446 tailcfg: move LogHeapPprof from Debug to c2n [capver 69]
And delete Debug.GoroutineDumpURL, which was already in c2n.

Updates #8923

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-16 20:35:04 -07:00
Maisem Ali
24509f8b22 cmd/k8s-operator: add support for control plane assigned groups
Previously we would use the Impersonate-Group header to pass through
tags to the k8s api server. However, we would do nothing for non-tagged
nodes. Now that we have a way to specify these via peerCaps respect those
and send down groups for non-tagged nodes as well.

For tagged nodes, it defaults to sending down the tags as groups to retain
legacy behavior if there are no caps set. Otherwise, the tags are omitted.

Updates #5055

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-08-16 19:40:47 -04:00
Brad Fitzpatrick
0913ec023b CODEOWNERS: add the start of an owners file
Updates tailscale/corp#13972

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-08-16 15:57:29 -07:00
Brad Fitzpatrick
b090d61c0f tailcfg: rename prototype field to reflect its status
(Added earlier today in #8916, 57da1f150)

Updates tailscale/corp#13969

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

Fixes #cleanup

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

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

Updates #6995

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

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

Updates #7781

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

Updates tailscale/corp#13025

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

Updates tailscale/corp#2549

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

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

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

Updates tailscale/corp#13890

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

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

Updates tailscale/corp#13775

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

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

Updates gokrazy/gokrazy#209

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

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

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

Updates tailscale/corp#10577

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

Updates tailscale/corp#10577

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

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

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

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

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

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

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

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

Updates tailscale/corp#5761

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

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

Updates #1909

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

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

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

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

Updates tailscale/corp#13856

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

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

Updates #6995

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

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

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

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

And with it,

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

Updates #8839
Updates tailscale/go#70

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

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

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

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

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

Fixes #8723

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

Updates tailscale/corp#13775

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

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

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

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

Updates tailscale/corp#5761

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

Updates #311.

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

Updates tailscale/corp#13775

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

Updates #6995

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

Updates tailscale/corp#13775

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

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

Updates tailscale/corp#5761

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

Updates #cleanup

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

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

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

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

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

Updates #8587

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

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

Updates tailscale/corp#13775

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

Updates tailscale/corp#13775

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

Updates #8419

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

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

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

Updates #cleanup

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

Updates #7726

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

Updates #7726

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

Updates #7726

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

Updates #7726

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

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

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

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

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

Updates #311.

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

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

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

Updates #8785

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

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

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

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

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

Fixes #7802

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

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

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

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

Fixes #8669

RELNOTE=adds "tailscale version --upstream"

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

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

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

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

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

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

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

Fixes #8284

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

Updates #1748

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

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

Updates #8760

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

Updates #cleanup

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

Updates tailscale/corp#12702

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

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

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

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

Updates #cleanup

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

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

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

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

Fixes #8725

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

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

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

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

Updates tailscale/corp#13025

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

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

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

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

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

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

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

Updates #6995

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

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

Also update accessors to use the new values.

Updates #4217

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

Updates #6995

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

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

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

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

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

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

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

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

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

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

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

Updates #7781

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

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

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

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

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

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

Updates #7781

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

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

Updates #6995

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

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

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

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

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

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

Updates tailscale/corp#12702

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

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

Fixes #8592

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

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

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

Fixes #8658

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

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

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

Fixes #8549

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

Updates #6995

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

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

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

02ed0e5e67

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

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

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

Updates #3576

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

Updates #8645

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

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

Updates: #391

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

Follows the examples from the KB[1].

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

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

Updates #8219

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

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

Updates #8603

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

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

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

Updates tailscale/corp#8641

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

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

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

Fixes #8635

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

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

Updates tailscale/corp#12894

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

Updates #8603

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

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

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

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

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

Updates #8493

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

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

Updates #8249

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

Updates tailscale/corp#9755

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

Updates #5853

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

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

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

Updates #cleanup

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

Updates  #8557

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

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

Updates tailscale/corp#12938
Updates #7826

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

Updates tailscale/corp#12590

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

Fixes #8463

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

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

Updates #8463

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

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

Updates tailscale/corp#7354

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

Fixes: #8511

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

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

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

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

Updates #8493

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

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

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

Attempt #2: Retrying flaky tests:

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

Updates #8493

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

Updates tailscale/corp#12830

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

Updates tailscale/corp#12754

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

Updates tailscale/corp#8427

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

Updates #cleanup

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

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

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

Updates tailscale/corp#7355

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

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

Updates #8413

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

For #391

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

Updates tailscale/corp#12146

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

Fixes #8375

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

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

Fixes #8457

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

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

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

Fixes #7975

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

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

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

Fixes #8444

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

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

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

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

Updates tailscale/corp#12146

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

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

Fixes #8425

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

Updates #8232

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

Updates tailscale/corp#5149

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

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

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

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

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

Fixes #7601

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

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

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

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

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

Fixes #8395

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

Fixes #8118

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

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

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

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

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

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

Updates #11492

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

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

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

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

Updates #4908

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

Updates #8302

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

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

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

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

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

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

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

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

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

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

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

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

Address code review comments

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

Address code review comments by Ross

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

* Improve error encoding, fix logic error

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

---------

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

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

Updates #cleanup

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

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

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

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

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

Updates #8320
Fixes #8315

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

Updates #8320

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

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

Updates #5853

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

Updates #6575

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

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

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

Updates #8097

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

Updates #8296

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

Signed-off-by: Jenny Zhang <jz@tailscale.com>
2023-06-07 22:12:18 -04:00
Brad Fitzpatrick
12f8c98823 util/cmpx: add package with cmp-like things from future Go releases
Updates #8296

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-06-07 12:41:31 -07:00
Andrea Gottardo
1c4a047ad0 version: detect tvOS by checking XPC_SERVICE_NAME (#8295)
Another change needed working towards #8282.

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

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
2023-06-07 12:19:31 -07:00
Marwan Sulaiman
f8f0b981ac portlist: remove async functionality
This PR removes all async functionality from the portlist package
which may be a breaking change for non-tailscale importers. The only
importer within this codebase (LocalBackend) is already using the synchronous
API so no further action needed.

Fixes #8171

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-06-07 10:22:09 -04:00
Andrea Gottardo
a353ae079b tool/gocross: handle TVOS_DEPLOYMENT_TARGET (#8292)
This is needed in order to build our network extension on tvOS. First step for #8282

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
2023-06-06 18:06:17 -07:00
Xe Iaso
43e230d4cd tsnet: document how to use Dir with multiple servers per process (#8286)
Followup from a conversation on the Gophers slack, also matches the
examples in the tsnet kb.

Closes #8287

Signed-off-by: Xe Iaso <xe@tailscale.com>
2023-06-06 15:20:00 -04:00
Marwan Sulaiman
5dd0b02133 portlist: add a synchronous Poll method
This is a follow up on PR #8172 that adds a synchronous Poll method
which allows for the Poller to be used as a zero value without needing
the constructor. The local backend is also changed to use the new API.
A follow up PR will remove the async functionality from the portlist package.

Updates #8171

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-06-05 23:10:42 -04:00
Derek Burdick
d3c8c3dd00 ssh/tailssh: Max Username Length 256 for linux
Max username length is increased to 256 on linux to match /usr/include/bits/local_lim.h

Fixes #8277

Signed-off-by: Derek Burdick <derek-burdick@users.noreply.github.com>
2023-06-05 18:04:30 -07:00
Denton Gentry
64f16f7f38 net/dnscache: use PreferGo on Windows.
Updates https://github.com/tailscale/tailscale/issues/5161

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-06-05 15:58:49 -07:00
Denton Gentry
6554a0cbec build_dist.sh: use $go consistently.
The invocation at the end unconditionally used
./tool/go, but the structuring on lines 14-17
sets up to use a different toolchain if the
platform requires it.

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

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-06-05 10:21:03 -07:00
License Updater
d17312265e licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-06-05 09:54:12 -07:00
Denton Gentry
4321d1d6e9 scripts/installer.sh: add sle-micro-rancher.
Fixes https://github.com/tailscale/tailscale/issues/5633

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-06-04 17:05:51 -07:00
DJRHails
2492ca2900 words: remove misspelling of trex
Signed-off-by: DJRHails <hello@hails.info>
2023-06-04 14:18:36 -07:00
Dominic Black
570cb018da ipn/localapi: require only read permission for WatchIPNBus (#7798)
Allow calls to `WatchIPNBus` to be permformed by clients with
Readonly permissions. This brings it in line with the permissions
required for `Status`, which also exposes the similar information.

This allows clients to get realtime updates about the tailnet
in their own applications, without needing to actively poll the
`Status` endpoint.

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

Signed-off-by: Dominic Black <dom@encore.dev>
2023-06-04 09:05:21 -07:00
Heiko Rothe
dc1d8826a2 ipn/ipnlocal: [serve/funnel] add forwarded host and proto header (#8224)
This replicates the headers also sent by the golang reverse proxy by
default.

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

Signed-off-by: Heiko Rothe <me@heikorothe.com>
2023-06-04 09:04:33 -07:00
Denton Gentry
67882ad35d scripts/installer.sh: add BlendOS support.
Fixes https://github.com/tailscale/tailscale/issues/8100

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-06-04 08:31:58 -07:00
Andrew Dunham
07eacdfe92 ipn/ipnlocal: renew certificates based on lifetime
Instead of renewing certificates based on whether or not they're expired
at a fixed 14-day period in the future, renew based on whether or not
we're more than 2/3 of the way through the certificate's lifetime. This
properly handles shorter-lived certificates without issue.

Updates #8204

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I5e82a9cadc427c010d04ce58c7f932e80dd571ea
2023-06-02 12:36:32 -04:00
License Updater
d06fac0ede licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-06-01 19:02:47 -07:00
Andrew Dunham
9d09c821f7 ipn/ipnlocal: add more logging during profile migration
Updates tailscale/corp#11883

Change-Id: I3a3ca8f25bfefca139115b25ec4161c069da7e4a
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2023-06-01 21:00:55 -04:00
Aaron Klotz
2aa8299c37 cmd/tailscaled, util/winutil: log our registry keys during tailscaled startup
In order to improve our ability to understand the state of policies and
registry settings when troubleshooting, we enumerate all values in all subkeys.
x/sys/windows does not already offer this, so we need to call RegEnumValue
directly.

For now we're just logging this during startup, however in a future PR I plan to
also trigger this code during a bugreport. I also want to log more than just
registry.

Fixes #8141

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2023-06-01 13:39:17 -06:00
Flakes Updater
88ee857bc8 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-06-01 09:11:17 -07:00
Vince Prignano
1a691ec5b2 cmd/k8s-operator: update controller-runtime to v0.15
Fixes #8170

Signed-off-by: Vince Prignano <vince@prigna.com>
2023-06-01 05:51:25 -07:00
Anton Tolchanov
6a156f6243 client/tailscale: support deauthorizing a device
This adds a new `SetAuthorized` method that allows setting device
authorization to true or false. I chose the method name to be consistent
with SetTags.

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

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-06-01 12:46:28 +01:00
dependabot[bot]
525b9c806f .github: bump peter-evans/create-pull-request from 5.0.0 to 5.0.1
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](5b4a9f6a9e...284f54f989)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-30 09:56:55 -07:00
David Anderson
fc5b137d25 release/dist/synology: build hi3535 as armv5, not armv7
This platform is technically an armv7, but has no hardware floating
point unit. armv5 is the only target Go understands to lack floating
point, so use that.

Updates #6860

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-05-29 21:01:40 -07:00
David Anderson
32e0ba5e68 release/dist/synology: build synology packages with cmd/dist
Updates #8217

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-05-29 14:18:37 -07:00
Denton Gentry
399a80785e wgengine/netstack: use ping6 on BSD platforms
Various BSD-derived operating systems including macOS and FreeBSD
require that ping6 be used for IPv6 destinations. The "ping" command
does not understand an IPv6 destination.

FreeBSD 13.x and later do handle IPv6 in the regular ping command,
but also retain a ping6 command. We use ping6 on all versions of
FreeBSD.

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

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-05-28 09:50:21 -07:00
David Anderson
c0b4a54146 release/dist/cli: correctly handle absolute build outputs in manifest
Some builders return absolute paths to build products already. When that
happens, the manifest writing logic shouldn't tack on another absolute
prefix.

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-05-25 19:52:40 -07:00
Brad Fitzpatrick
c4fe9c536d go.toolchain.rev: bump, again
For https://go-review.googlesource.com/c/go/+/498398

Updates tailscale/go#63
Updates tailscale/go#64

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-25 15:50:24 -07:00
valscale
370b2c37e0 tka: fix go vet complaint on copy of lock value in tailchonk_test.go (#8208)
go vet complains when we copy a lock value. Create clone function that
copies everything but the lock value.

Fixes #8207

Signed-off-by: Val <valerie@tailscale.com>
2023-05-25 13:34:13 -07:00
Brad Fitzpatrick
cb94ddb7b8 go.toolchain.rev: bump
Updates tailscale/go#63
Updates tailscale/go#64

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-25 12:21:39 -07:00
Andrea Gottardo
66f97f4bea tka: provide authority StateID in NetworkLockStatus response (#8200)
Fixes #8201.

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
2023-05-24 13:36:25 -07:00
Marwan Sulaiman
e32e5c0d0c portlist: add Poller.IncludeLocalhost option
This PR parameterizes receiving loopback updates from the portlist package.
Callers can now include services bound to localhost if they want.
Note that this option is off by default still.

Fixes #8171

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-05-24 13:26:16 -04:00
Will Norris
3d180a16c3 VERSION.txt: this is v1.43.0
Signed-off-by: Will Norris <will@tailscale.com>
2023-05-24 09:03:59 -07:00
Charlotte Brandhorst-Satzkorn
4e86857313 ssh/tailssh: add ssh session recording failed event type
This change introduces a SSHSessionRecordingFailed event type
that is used when a session recording fails to start or fails during a
session, and the on failure indicates that it should fail open.

Updates tailscale/corp#9967

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-05-22 17:39:01 -07:00
salman aljammaz
745ee97973 api.md: update device authorized API docs to allow for deauth (#8162)
The authorize device API (/api/v2/device/{deviceID}/authorized)
will soon allow device deauthorisation.

Fixes corp#10160.

Signed-off-by: salman <salman@tailscale.com>
2023-05-22 12:52:40 +01:00
Brad Fitzpatrick
a4fd4fd845 ssh/tailssh: fix regression after LDAP support
58ab66ec51 added LDAP support
for #4945 by shelling out to getdent.

It was supposed to fall back to the old method when getdent wasn't
found, but some variable name confusion (uid vs username) meant the
old path wasn't calling the right lookup function (user.LookupId
instead of user.Lookup).

Which meant that changed probably also broke FreeBSD and macOS SSH
support in addition to the reported OpenWRT regression.

The gokrazy support didn't look right either.

Fixes #8180

Change-Id: I273bbe96fe98b2517fbf0335fd476b483c051554
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-21 07:53:25 -07:00
James Tucker
e3cb982139 words: shell-ebrating crustacean chaos
Hey team! I've been diving deep into the code ocean for the past few
hours, tackling those sneaky race conditions that were threatening our
database. It was quite the crabby situation, but fear not! It's friday
and I've emerged and I'm ready to shell-ebrate with some punny word
additions. 🎉

This commit introduces a shell-shocking array of crustaceans to our word
list. From the lively lobsters to the clever prawns.

Signed-off-by: James Tucker <james@tailscale.com>
2023-05-19 14:15:26 -07:00
valscale
5ae786988c derp: remove default logging of disconnecting clients (#8163)
~97% of the log messages derper outputs are related to the normal
non-error state of a client disconnecting in some manner. Add a
verbose logging feature that only logs these messages when enabled.

Fixes #8024

Signed-off-by: Val <valerie@tailscale.com>
2023-05-18 15:35:16 -07:00
Maisem Ali
0ca8bf1e26 ssh/tailssh: close tty on session close
We were only closing on side of the pty/tty pair.
Close the other side too.

Thanks to @fritterhoff for reporting and debugging the issue!

Fixes #8119

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-05-18 09:35:42 -07:00
Gabriel Martinez
03e848e3b5 cmd/k8s-operator: add support for priorityClassName
Updates #8155

Signed-off-by: Gabriel Martinez <gabrielmartinez@sisti.pt>
2023-05-17 15:28:06 -07:00
Derek Kaser
7c88eeba86 cmd/tailscale: allow Tailscale to work with Unraid web interface (#8062)
Updates tailscale/tailscale#8026

Signed-off-by: Derek Kaser <derek.kaser@gmail.com>
2023-05-17 13:26:39 -07:00
Sonia Appasamy
f0ee03dfaf cmd/tailscale/cli: [serve] add reset flag
Usage: `tailscale serve reset`

Fixes #8139

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-05-16 16:19:08 -04:00
Brad Fitzpatrick
4664318be2 client/tailscale: revert CreateKey API change, add Client.CreateKeyWithExpiry
The client/tailscale is a stable-ish API we try not to break. Revert
the Client.CreateKey method as it was and add a new
CreateKeyWithExpiry method to do the new thing. And document the
expiry field and enforce that the time.Duration can't be between in
range greater than 0 and less than a second.

Updates #7143
Updates #8124 (reverts it, effectively)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-12 21:05:12 -07:00
shayne
678bb92bb8 cmd/tailscale/cli: [up] fix CreateKey missing argument (#8124)
Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
2023-05-12 02:03:17 -04:00
Matt Brown
9b6e48658f client: allow the expiry time to be specified for new keys
Adds a parameter for create key that allows a number of seconds
(less than 90) to be specified for new keys.

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

Signed-off-by: Matthew Brown <matthew@bargrove.com>
2023-05-11 22:05:18 -07:00
Maisem Ali
85215ed58a cmd/k8s-operator: handle NotFound secrets
getSingleObject can return `nil, nil`, getDeviceInfo was not handling
that case which resulted in panics.

Fixes #7303

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-05-11 18:26:11 -07:00
Brad Fitzpatrick
b69059334b util/set: add a basic map-based Set type
We have two other types of Sets here. Add the basic obvious one too.

Needed for a change elsewhere.

Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-11 15:17:34 -07:00
Joe Tsai
84c99fe0d9 logtail: be less aggressive about re-uploads (#8117)
The retry logic was pathological in the following ways:

* If we restarted the logging service, any pending uploads
would be placed in a retry-loop where it depended on backoff.Backoff,
which was too aggresive. It would retry failures within milliseconds,
taking at least 10 retries to hit a delay of 1 second.

* In the event where a logstream was rate limited,
the aggressive retry logic would severely exacerbate the problem
since each retry would also log an error message.
It is by chance that the rate of log error spam
does not happen to exceed the rate limit itself.

We modify the retry logic in the following ways:

* We now respect the "Retry-After" header sent by the logging service.

* Lacking a "Retry-After" header, we retry after a hard-coded period of
30 to 60 seconds. This avoids the thundering-herd effect when all nodes
try reconnecting to the logging service at the same time after a restart.

* We do not treat a status 400 as having been uploaded.
This is simply not the behavior of the logging service.

Updates #tailscale/corp#11213

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-05-11 12:52:35 -07:00
James Tucker
da90fab899 net/netcheck: reenable TestBasic on Windows
This test was either fixed by intermediate changes or was mis-flagged as
failing during #7876 triage.

Updates #7876

Signed-off-by: James Tucker <jftucker@gmail.com>
2023-05-10 20:16:35 -07:00
James Tucker
ca49b29582 tsnet: reenable TestLoopbackSOCKS5 on Windows
This test was either fixed in the intermediate time or mis-flagged
during the #7876 triage, but is now passing.

Updates #7876

Signed-off-by: James Tucker <jftucker@gmail.com>
2023-05-10 20:15:41 -07:00
Brad Fitzpatrick
cb2fd5be92 cmd/tsconnect: fix forgotten API change for wasm
Fix regression from 6e967446e4

Updates #8036

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-09 21:23:29 -07:00
David Anderson
d27a6e1c53 tool/gocross: fix incorrect relpath usage in bootstrap script
The subshell in which gocross gets built cd's to the corp checkout dir
near the top, so all future references to corp repository files should
be simple relative paths, and not reference $repo_root. When $repo_root
is an absolute path, it doesn't matter and everything works out, but on
some OSes and shells and invocations, $repo_root is a completely relative
path that is invalidated by the "cd".

Fixes tailscale/corp#11183

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-05-09 16:19:46 -07:00
Brad Fitzpatrick
4f454f4122 util/codegen: support embedded fields
I noticed cmd/{cloner,viewer} didn't support structs with embedded
fields while working on a change in another repo. This adds support.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-09 15:40:17 -07:00
Will Norris
ea84fc9ad2 net/sockstats: wait before reporting battery usage
Wait 2 minutes before we start reporting battery usage. There is always
radio activity on initial startup, which gets reported as 100% high
power usage.  Let that settle before we report usage data.

Updates tailscale/corp#9230

Signed-off-by: Will Norris <will@tailscale.com>
2023-05-09 10:18:16 -07:00
salman
1ce08256c0 release/dist: add deb/rpm arch mappings for mipses
According to https://wiki.debian.org/SupportedArchitectures Debian does
not support big-endian mips64, so that one remains disabled.

According to https://fedoraproject.org/wiki/Architectures Fedora only
supports little-endian mips, so leaving both big-endian ones out too.

Updates #8005.

Signed-off-by: salman <salman@tailscale.com>
2023-05-09 17:31:13 +01:00
Craig Rodrigues
827abbeeaa cmd/k8s-operator: print version in startup logs
Fixes: #7813

Signed-off-by: Craig Rodrigues <rodrigc@crodrigues.org>
2023-05-08 18:56:21 -07:00
License Updater
d1ecb1f43b licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-05-08 16:48:36 -07:00
Brad Fitzpatrick
a743b66f9d ssh/tailssh: move some user-related code into new user.go
The previous commit 58ab66e added ssh/tailssh/user.go as part of
working on #4945. So move some more user-related code over to it.

Updates #cleanup

Change-Id: I24de66df25ffb8f867e1a0a540d410f9ef16d7b0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-08 13:47:59 -07:00
Brad Fitzpatrick
58ab66ec51 ssh/tailssh: support LDAP users for Tailscale SSH
Fixes #4945

Change-Id: Ie013cb47684cb87928a44f92c66352310bfe53f1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-08 11:29:14 -07:00
Chenyang Gao
e8b06b2232 version: properly handle vcs.modified when it's "false"
Current code will set the "dirty" field of VersionInfo always "true"
if vcs.modified flag is there. No matter whether the flag is "true" or
"false".  It will make sense to set this field due to vcs.modified
value, not only the existence of the key.

Signed-off-by: Chenyang Gao <gps949@outlook.com>
2023-05-07 09:15:38 -07:00
License Updater
df8b1b2179 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-05-06 23:48:30 -07:00
License Updater
4d730e154c licenses: update android licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2023-05-06 16:33:37 -07:00
Chenyang Gao
b9fb8ac702 fix sys.Set(router) issue will crash the daemon in some OSs
Signed-off-by: Chenyang Gao <gps949@outlook.com>

in commit 6e96744, the tsd system type has been added.
Which will cause the daemon will crash on some  OSs (Windows, darwin and so on).
The root cause is that on those OSs, handleSubnetsInNetstack() will return true and set the conf.Router with a wrapper. 
Later in NewUserspaceEngine() it will do subsystem set and found that early set router mismatch to current value, then panic.
2023-05-06 14:11:43 -07:00
James Tucker
5c38f0979e tsweb/promvarz: fix repeated expvar definition in test
expvar can only be defined once, so running tests with a repeat counter
will fail if the variables are defined inside of the test function.

Observed failure:

```
--- FAIL: TestHandler (0.00s)
panic: Reuse of exported var name: gauge_promvarz_test_expvar
 [recovered]
        panic: Reuse of exported var name: gauge_promvarz_test_expvar

goroutine 9 [running]:
testing.tRunner.func1.2({0x100f267e0, 0x1400026e770})
        /usr/local/go/src/testing/testing.go:1526 +0x1c8
testing.tRunner.func1()
        /usr/local/go/src/testing/testing.go:1529 +0x364
panic({0x100f267e0, 0x1400026e770})
        /usr/local/go/src/runtime/panic.go:884 +0x1f4
log.Panicln({0x140000b8e20?, 0x1a?, 0x1400026e750?})
        /usr/local/go/src/log/log.go:398 +0x60
expvar.Publish({0x100e2b21d, 0x1a}, {0x100fd7a08?, 0x140000232c0})
        /usr/local/go/src/expvar/expvar.go:284 +0xc0
expvar.NewInt(...)
        /usr/local/go/src/expvar/expvar.go:304
tailscale.com/tsweb/promvarz.TestHandler(0x14000082b60)
        /Users/charlotte/ts-src/tailscale/tsweb/promvarz/promvarz_test.go:18 +0x5c
testing.tRunner(0x14000082b60, 0x100fd5858)
        /usr/local/go/src/testing/testing.go:1576 +0x104
created by testing.(*T).Run
        /usr/local/go/src/testing/testing.go:1629 +0x370
FAIL    tailscale.com/tsweb/promvarz    0.149s
```

Fixes #8065
Signed-off-by: James Tucker <james@tailscale.com>
2023-05-06 09:53:09 -07:00
Charlotte Brandhorst-Satzkorn
024d48d9c1 tailcfg: bump capability version
This change bumps the capability version to 62, after support for
sending SSHEventNotificationRequests to control via noise for failure
events was introduced.

Updates tailscale/corp#9967

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-05-05 14:22:59 -07:00
Charlotte Brandhorst-Satzkorn
29ded8f9f9 ssh/tailssh,tailcfg: add connID to ssheventnotifyrequest and castheader
This change adds a ConnectionID field to both SSHEventNotifyRequest and
CastHeader that identifies the ID of a connection to the SSH server.

Updates tailscale/corp#9967

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-05-05 14:22:59 -07:00
Charlotte Brandhorst-Satzkorn
68307c1411 ssh/tailssh: send ssh event notifications on recording failures
This change sends an SSHEventNotificationRequest over noise when a
SSH session is set to fail closed and the session is unable to start
because a recorder is not available or a session is terminated because
connection to the recorder is ended. Each of these scenarios have their
own event type.

Updates tailscale/corp#9967

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-05-05 14:22:59 -07:00
Charlotte Brandhorst-Satzkorn
2804327074 tailcfg: update rename SSHFailureNotifyRequest and add EventType
This change renames SSHFailureNotifyRequest to SSHEventNotifyRequest
to better reflect the additional events we could add in the future.
This change also adds an EventType used to catagories the events.

Updates tailscale/corp#9967

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-05-05 14:22:59 -07:00
Charlotte Brandhorst-Satzkorn
8d3d48e000 ipn/ipnlocal: add NodeKey func to return the public node NodeKey
This change introduces a NodeKey func on localbackend that returns the
public node key.

Updates tailscale/corp#9967

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-05-05 14:22:59 -07:00
Brad Fitzpatrick
8864112a0c ipn/ipnlocal: bound how long cert fetchher checks for existing DNS records
It was supposed to be best effort but in some cases (macsys at least,
per @marwan-at-work) it hangs and exhausts the whole context.Context
deadline so we fail to make the SetDNS call to the server.

Updates #8067
Updates #3273 etc

Change-Id: Ie1f04abe9689951484748aecdeae312afbafdb0f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-05 12:31:45 -07:00
James Tucker
9ed3a061c3 net/netns: fix segv when no tailscale interface is found
`interfaces.Tailscale()` returns all zero values when it finds no
Tailscale interface and encounters no errors. The netns package was
treating no error as a signal that it would receive a non-zero pointer
value leading to nil pointer dereference.

Observed in:

```
--- FAIL: TestGetInterfaceIndex (0.00s)
    --- FAIL: TestGetInterfaceIndex/IP_and_port (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
        panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x1029eb7d8]

goroutine 7 [running]:
testing.tRunner.func1.2({0x102a691e0, 0x102bc05c0})
        /Users/raggi/.cache/tailscale-go/src/testing/testing.go:1526 +0x1c8
testing.tRunner.func1()
        /Users/raggi/.cache/tailscale-go/src/testing/testing.go:1529 +0x384
panic({0x102a691e0, 0x102bc05c0})
        /Users/raggi/.cache/tailscale-go/src/runtime/panic.go:884 +0x204
tailscale.com/net/netns.getInterfaceIndex(0x14000073f28, 0x1028d0284?, {0x1029ef3b7, 0xa})
        /Users/raggi/src/github.com/tailscale/tailscale/net/netns/netns_darwin.go:114 +0x228
tailscale.com/net/netns.TestGetInterfaceIndex.func2(0x14000138000)
        /Users/raggi/src/github.com/tailscale/tailscale/net/netns/netns_darwin_test.go:37 +0x54
testing.tRunner(0x14000138000, 0x140000551b0)
        /Users/raggi/.cache/tailscale-go/src/testing/testing.go:1576 +0x10c
created by testing.(*T).Run
        /Users/raggi/.cache/tailscale-go/src/testing/testing.go:1629 +0x368
FAIL    tailscale.com/net/netns 0.824s
```

Fixes #8064

Signed-off-by: James Tucker <jftucker@gmail.com>
2023-05-05 11:31:43 -07:00
Brad Fitzpatrick
6e967446e4 tsd: add package with System type to unify subsystem init, discovery
This is part of an effort to clean up tailscaled initialization between
tailscaled, tailscaled Windows service, tsnet, and the mac GUI.

Updates #8036

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-04 14:21:59 -07:00
Derek Kaser
0d7303b798 various: add detection and Taildrop for Unraid
Updates tailscale/tailscale#8025

Signed-off-by: Derek Kaser <derek.kaser@gmail.com>
2023-05-04 13:40:13 -07:00
Flakes Updater
d1ce7a9b5e go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-05-04 12:55:01 -07:00
James Tucker
5def4f4a1c go.mod: bump goreleaser deps
Periodic update for start of cycle. goreleaser is not updated to v2 yet,
but indirects updated.

Updates #8043

Signed-off-by: James Tucker <james@tailscale.com>
2023-05-04 12:32:24 -07:00
Flakes Updater
1c6ff310ae go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-05-04 12:31:31 -07:00
James Tucker
48605226dd go.mod: bump gvisor
Periodic update for start of cycle.

Updates #8043

Signed-off-by: James Tucker <james@tailscale.com>
2023-05-04 12:30:27 -07:00
Maisem Ali
f46c1aede0 go.mod: bump k8s libs
The key is to update sigs.k8s.io/controller-runtime and let it update others.

Updates #8043

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-05-04 12:00:03 -07:00
Brad Fitzpatrick
73d128238e envknob: support tailscaled-env.txt on macOS too
Updates #3707

Co-authored-by: Marwan Sulaiman <marwan@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-04 10:27:59 -07:00
Anton Tolchanov
787fc41fa4 scripts/installer.sh: check connectivity with pkgs.tailscale.com
Installer script relies on pkgs.tailscale.com being reachable, both for
checking what Linux distros are supported, but also for actually
downloading repo configuration files, gpg keys and packages themselves.

This change adds a simple reachability check which will print an error
message when pkgs.tailscale.com is not reachable.

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

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-05-04 16:49:56 +02:00
Flakes Updater
5783adcc6f go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-05-03 19:56:23 -07:00
License Updater
503b6dd8be licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-05-03 19:56:00 -07:00
Brad Fitzpatrick
9e9ea6e974 go.mod: bump all deps possible that don't break the build
This holds back gvisor, kubernetes, goreleaser, and esbuild, which all
had breaking API changes.

Updates #8043
Updates #7381
Updates #8042 (updates u-root which adds deps)

Change-Id: I889759bea057cd3963037d41f608c99eb7466a5b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-03 19:52:54 -07:00
M. J. Fromberger
459744c9ea .github: mark bots for exemption by issuebot (#8041)
#cleanup

Change-Id: I13757ee20240166af72606d166e840ae3ee797d7
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2023-05-03 18:36:19 -07:00
License Updater
7675d323fa licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2023-05-03 15:48:09 -07:00
dependabot[bot]
270942094f build(deps): bump github.com/docker/docker
Bumps [github.com/docker/docker](https://github.com/docker/docker) from 20.10.16+incompatible to 20.10.24+incompatible.
- [Release notes](https://github.com/docker/docker/releases)
- [Commits](https://github.com/docker/docker/compare/v20.10.16...v20.10.24)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-03 15:40:46 -07:00
Maisem Ali
be190e990f ssh/tailssh: restore support for recording locally
We removed it earlier in 916aa782af, but we still want to support it for some time longer.

Updates tailscale/corp#9967

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-05-03 15:00:01 -07:00
Brad Fitzpatrick
4d7927047c wgengine/magicsock: annotate, skip flaky TestIsWireGuardOnlyPickEndpointByPing
Updates #8037
Updates #7826

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-03 14:58:28 -07:00
Charlotte Brandhorst-Satzkorn
ddb4040aa0 wgengine/magicsock: add address selection for wireguard only endpoints (#7979)
This change introduces address selection for wireguard only endpoints.
If a endpoint has not been used before, an address is randomly selected
to be used based on information we know about, such as if they are able
to use IPv4 or IPv6. When an address is initially selected, we also
initiate a new ICMP ping to the endpoints addresses to determine which
endpoint offers the best latency. This information is then used to
update which endpoint we should be using based on the best possible
route. If the latency is the same for a IPv4 and an IPv6 address, IPv6
will be used.

Updates #7826

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-05-02 17:49:56 -07:00
Brad Fitzpatrick
c1e6888fc7 derp: add a README.md with some docs
Updates #docs

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-05-02 13:42:25 -07:00
Maisem Ali
3ae7140690 net/tstun: handle exit nodes in NAT configs
In the case where the exit node requires SNAT, we would SNAT all traffic not just the
traffic meant to go through the exit node. This was a result of the default route being
added to the routing table which would match basically everything.

In this case, we need to account for all peers in the routing table not just the ones
that require NAT.

Fix and add a test.

Updates tailscale/corp#8020

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-05-02 13:03:30 -07:00
Andrew Dunham
bcf7b63d7e wgengine/magicsock: add hysteresis to endpoint selection
Avoid selecting an endpoint as "better" than the current endpoint if the
total latency improvement is less than 1%. This adds some hysteresis to
avoid flapping between endpoints for a minimal improvement in latency.

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: If8312e1768ea65c4b4d4e13d8de284b3825d7a73
2023-05-02 08:56:16 -07:00
Tom DNetto
c5bf868940 ssh/tailssh: improve debug logging around revoked sessions
Updates https://github.com/tailscale/corp/issues/10943
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-05-01 14:10:16 -07:00
Andrew Dunham
42fd964090 control/controlclient: use dnscache.Resolver for Noise client
This passes the *dnscache.Resolver down from the Direct client into the
Noise client and from there into the controlhttp client. This retains
the Resolver so that it can share state across calls instead of creating
a new resolver.

Updates #4845
Updates #6110

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ia5d6af1870f3b5b5d7dd5685d775dcf300aec7af
2023-05-01 13:22:10 -07:00
License Updater
979d29b5f5 licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-05-01 09:13:43 -07:00
Anton Tolchanov
1f4a34588b .github: test installer script in CI in docker
Every time we change `installer.sh`, run it in a few docker
containers based on different Linux distros, just as a simple test.

Also includes a few changes to the installer script itself to make
installation work in docker:
- install dnf config-manager command before running it
- run zypper in non-interactive mode
- update pacman indexes before installing packages

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

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2023-04-29 08:48:19 +01:00
Denton Gentry
a82f275619 cmd/sniproxy: Set App name in tsnet hostinfo
Updates #1748
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-04-28 21:50:30 -07:00
James Tucker
b3c3a9f174 syncs: add Map.Len to get the length of the Map
I need this for a corp change where I have a set as a queue, and make a
different decisison if the set is empty.

Updates tailscale/corp#10344

Signed-off-by: James Tucker <james@tailscale.com>
2023-04-27 19:10:28 -07:00
ayanamist
042f82ea32 build_dist.sh: make cross-compilation friendly for env CC specified
Signed-off-by: ayanamist <ayanamist@gmail.com>
2023-04-27 18:51:59 -07:00
dependabot[bot]
633d08bd7b .github: Bump actions/setup-go from 3 to 4
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 3 to 4.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-04-27 17:21:59 -07:00
James Tucker
d35ce1add9 syncs: add documentation to Map.Range
Updates #cleanup

Signed-off-by: James Tucker <james@tailscale.com>
2023-04-27 17:07:07 -07:00
Charlotte Brandhorst-Satzkorn
c3ab36cb9d words: charlotte 1 spell-checking-linter 0 (#7993)
Can't have a dupe when the dupe is wrong. Clearly we need to up
our spell checking game. Did anyone say AI?

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-04-27 08:58:52 -07:00
James Tucker
8032b966a1 .github/workflows: add recency bias to action cache keys
The action cache restore process either matches the restore key pattern
exactly, or uses a matching prefix with the most recent date.

If the restore key is an exact match, then no updates are uploaded, but
if we've just computed tests executions for more recent code then we
will likely want to use those results in future runs.

Appending run_id to the cache key will give us an always new key, and
then we will be restore a recently uploaded cache that is more likely
has a higher overlap with the code being tested.

Updates #7975

Signed-off-by: James Tucker <james@tailscale.com>
2023-04-26 21:36:53 -07:00
Kyle Carberry
d78b334964 cmd/derper: disable http2
DERP doesn't support HTTP/2. If an HTTP/2 proxy was placed in front of
a DERP server requests would fail because the connection would
be initialized with HTTP/2, which the DERP client doesn't support.

Signed-off-by: Kyle Carberry <kyle@carberry.com>
2023-04-26 20:45:32 -07:00
Charlotte Brandhorst-Satzkorn
161d1d281a net/ping,netcheck: add v6 pinging capabilities to pinger (#7971)
This change adds a v6conn to the pinger to enable sending pings to v6
addrs.

Updates #7826

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-04-26 15:59:37 -07:00
Denton Gentry
1145b9751d VERSION.txt: this is v1.41.0
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-04-26 15:58:30 -07:00
Brad Fitzpatrick
1e876a3c1d ipn/ipnlocal: fix fmt format arg type mismatch in log line
It was printing like "v0xxxxxxxx" after version.Long became a func
in 8b2ae47c31.

Fixes #7976

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-26 14:28:09 -07:00
Maisem Ali
a8f10c23b2 cmd/tailscale/cli: [up] reuse --advertise-tags for OAuth key generation
We need to always specify tags when creating an AuthKey from an OAuth key.

Check for that, and reuse the `--advertise-tags` param.

Updates #7982

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-04-26 14:17:29 -07:00
Brad Fitzpatrick
b2b5379348 cmd/tailscale/cli: [up] change oauth authkey format
Updates #7982

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-26 13:36:17 -07:00
Brad Fitzpatrick
13de36303d cmd/tailscale/cli: [up] add experimental oauth2 authkey support
Updates #7982

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-26 12:25:42 -07:00
James Tucker
095d3edd33 ipn/ipnlocal: reenable profile tests on Windows
This fix does not seem ideal, but the test infrastructure using a local
goos doesn't seem to avoid all of the associated challenges, but is
somewhat deeply tied to the setup.

The core issue this addresses for now is that when run on Windows there
can be no code paths that attempt to use an invalid UID string, which on
Windows is described in [1].

For the goos="linux" tests, we now explicitly skip the affected
migration code if runtime.GOOS=="windows", and for the Windows test we
explicitly use the running users uid, rather than just the string
"user1". We also now make the case where a profile exists and has
already been migrated a non-error condition toward the outer API.

Updates #7876

[1] https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers

Signed-off-by: James Tucker <jftucker@gmail.com>
2023-04-26 11:33:38 -07:00
James Tucker
43819309e1 .github/workflows: split tests and benchmarks for caching
Benchmark flags prevent test caching, so benchmarks are now executed
independently of tests.

Fixes #7975

Signed-off-by: James Tucker <james@tailscale.com>
2023-04-26 10:49:38 -07:00
Maisem Ali
1b8a0dfe5e ssh/tailssh: also handle recording upload failure during writes
Previously we would error out when the recording server disappeared after the in memory
buffer filled up for the io.Copy. This makes it so that we handle failing open correctly
in that path.

Updates tailscale/corp#9967

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-04-25 19:40:46 -07:00
shayne
018a382729 cmd/tailscale/cli: [serve] fix MinGW path conversion (#7964)
Fixes #7963

Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
2023-04-25 13:07:17 -04:00
License Updater
2e07245384 licenses: update android licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2023-04-25 09:10:26 -07:00
License Updater
aa87e999dc licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-04-25 09:10:05 -07:00
Maisem Ali
f58751eb2b net/packet: add NAT support for DCCP and GRE
Updates tailscale/corp#8020

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-04-24 15:30:50 -07:00
Marwan Sulaiman
ce11c82d51 ipn/store/awsstore: persist state with intelligent tiering
Fixes #6784

This PR makes it so that we can persist the tailscaled state with
intelligent tiering which increases the capacity from 4kb to 8kb

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-04-24 14:35:13 -04:00
Brad Fitzpatrick
90ba26cea1 net/netcheck: fix crash when IPv6 kinda but not really works
Looks like on some systems there's an IPv6 address, but then opening
a IPv6 UDP socket fails later. Probably some firewall. Tolerate it
better and don't crash.

To repro: check the "udp6" to something like "udp7" (something that'll
fail) and run "go run ./cmd/tailscale netcheck" on a machine with
active IPv6. It used to crash and now it doesn't.

Fixes #7949

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-22 17:53:39 -07:00
Maisem Ali
7778d708a6 ssh/tailssh: handle dialing multiple recorders and failing open
This adds support to try dialing out to multiple recorders each
with a 5s timeout and an overall 30s timeout. It also starts respecting
the actions `OnRecordingFailure` field if set, if it is not set
it fails open.

Updates tailscale/corp#9967

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-04-22 10:23:13 -07:00
Maisem Ali
f66ddb544c tailcfg: add SSHRecorderFailureAction and SSHRecordingFailureNotifyRequest
This allows control to specify how to handle situations where the recorder
isn't available.

Updates tailscale/corp#9967

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-04-22 10:23:13 -07:00
Flakes Updater
e3b2250e26 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply@tailscale.com>
2023-04-21 21:36:21 -07:00
Andrew Dunham
6f521c138d tailcfg: add CanPort80 field to DERPNode
A follow-up PR will start using this field after we set it in our
production DERPMap.

Updates #7925

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Idb41b79e6055dddb8944f79d91ad4a186ace98c7
2023-04-21 14:15:26 -04:00
Andrew Dunham
04a3118d45 net/tstun: add tests for captureHook
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I630f852d9f16c951c721b34f2bc4128e68fe9475
2023-04-21 14:05:20 -04:00
Denton Gentry
c791e64881 scripts/installer: add Deepin, RisiOS.
Fixes https://github.com/tailscale/tailscale/issues/7862
Fixes https://github.com/tailscale/tailscale/issues/7899

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-04-20 18:51:50 -07:00
Mihai Parparita
7330aa593e all: avoid repeated default interface lookups
On some platforms (notably macOS and iOS) we look up the default
interface to bind outgoing connections to. This is both duplicated
work and results in logspam when the default interface is not available
(i.e. when a phone has no connectivity, we log an error and thus cause
more things that we will try to upload and fail).

Fixed by passing around a netmon.Monitor to more places, so that we can
use its cached interface state.

Fixes #7850
Updates #7621

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2023-04-20 15:46:01 -07:00
Will Norris
7f17e04a5a log/sockstatlog: bump logInterval to 10 seconds
We are seeing indications that some devices are still getting into an
upload loop.  Bump logInterval in case these devices are on slow
connections that are taking more than 3 seconds to uploads sockstats.

Updates #7719

Signed-off-by: Will Norris <will@tailscale.com>
2023-04-20 11:37:06 -07:00
Mihai Parparita
4722f7e322 all: move network monitoring from wgengine/monitor to net/netmon
We're using it in more and more places, and it's not really specific to
our use of Wireguard (and does more just link/interface monitoring).

Also removes the separate interface we had for it in sockstats -- it's
a small enough package (we already pull in all of its dependencies
via other paths) that it's not worth the extra complexity.

Updates #7621
Updates #7850

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2023-04-20 10:15:59 -07:00
Andrew Dunham
3ede3aafe4 ipn/localapi: also verify STUN queries work in 'debug derp'
Updates #6526

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I274b7ed53ee0be3fb94fdb00cafe06a1d676e1cf
2023-04-20 10:14:59 -04:00
James Tucker
f844791e15 safesocket: enable test to run on Windows unpriviliged
I manually tested that the code path that relaxes pipe permissions is
not executed when run with elevated priviliges, and the test also passes
in that case.

Updates #7876

Signed-off-by: James Tucker <jftucker@gmail.com>
2023-04-19 19:06:18 -07:00
James Tucker
cd35a79136 syncs: relax TestWatchMultipleValues timing on Windows
The test is re-enabled for Windows with a relaxed time assertion.

On Windows the runtime poller currently does not have sufficient
resolution to meet the normal requirements for this test.

See https://github.com/golang/go/issues/44343 for background.

Updates #7876

Signed-off-by: James Tucker <jftucker@gmail.com>
2023-04-19 19:00:34 -07:00
Andrew Dunham
f85dc6f97c ci: add more lints (#7909)
This is a follow-up to #7905 that adds two more linters and fixes the corresponding findings. As per the previous PR, this only flags things that are "obviously" wrong, and fixes the issues found.

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I8739bdb7bc4f75666a7385a7a26d56ec13741b7c
2023-04-19 21:54:19 -04:00
dependabot[bot]
5acc7c4b1e .github: Bump ruby/action-slack from 3.0.0 to 3.2.1
Bumps [ruby/action-slack](https://github.com/ruby/action-slack) from 3.0.0 to 3.2.1.
- [Release notes](https://github.com/ruby/action-slack/releases)
- [Commits](https://github.com/ruby/action-slack/compare/v3.0.0...v3.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-04-19 18:41:00 -07:00
dependabot[bot]
c328770184 .github: Bump peter-evans/create-pull-request from 4.2.4 to 5.0.0
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 4.2.4 to 5.0.0.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](38e0b6e68b...5b4a9f6a9e)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-04-19 18:38:14 -07:00
License Updater
588a234fdc licenses: update win/apple licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-04-19 17:06:12 -07:00
Maisem Ali
c3ef6fb4ee ipn/ipnlocal: handle masquerade addresses in PeerAPI
Without this, the peer fails to do anything over the PeerAPI if it
has a masquerade address.

```
Apr 19 13:58:15 hydrogen tailscaled[6696]: peerapi: invalid request from <ip>:58334: 100.64.0.1/32 not found in self addresses
```

Updates #8020

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-04-19 15:51:44 -07:00
Maisem Ali
85de580455 net/tsdial: do not use proxies when dialing out to PeerAPI
Found this when adding a test that does a ping over PeerAPI.

Our integration tests set up a trafficTrap to ensure that tailscaled
does not call out to the internet, and it does so via a HTTP_PROXY.

When adding a test for pings over PeerAPI, it triggered the trap and investigation
lead to the realization that we were not removing the Proxy when trying to
dial out to the PeerAPI.

Updates tailscale/corp#8020

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2023-04-19 15:51:44 -07:00
Mihai Parparita
d0906cda97 net/sockstats: expose debug info
Exposes some internal state of the sockstats package via the C2N and
PeerAPI endpoints, so that it can be used for debugging. For now this
includes the estimated radio on percentage and a second-by-second view
of the times the radio was active.

Also fixes another off-by-one error in the radio on percentage that
was leading to >100% values (if n seconds have passed since we started
to monitor, there may be n + 1 possible seconds where the radio could
have been on).

Updates tailscale/corp#9230

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2023-04-19 14:33:12 -07:00
Will Norris
7c386ca6d2 net/sockstats: fix calculation of radio power usage
When splitting the radio monitor usage array, we were splitting at now %
3600 to get values into chronological order.  This caused the value for
the final second to be included at the beginning of the ordered slice
rather than the end.  If there was activity during that final second, an
extra five seconds of high power usage would get recorded in some cases.
This could result in a final calculation of greater than 100% usage.

This corrects that by splitting values at (now+1 % 3600).

This also simplifies the percentage calculation by always rounding
values down, which is sufficient for our usage.

Signed-off-by: Will Norris <will@tailscale.com>
2023-04-19 13:18:02 -07:00
License Updater
7f057d7489 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2023-04-19 10:22:36 -07:00
Mihai Parparita
c7cea825ae net/netns: don't log errors when we can't get the default route on Darwin
It's somewhat common (e.g. when a phone has no reception), and leads to
lots of logspam.

Updates #7850

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2023-04-18 09:57:15 -07:00
Andrew Dunham
280255acae various: add golangci-lint, fix issues (#7905)
This adds an initial and intentionally minimal configuration for
golang-ci, fixes the issues reported, and adds a GitHub Action to check
new pull requests against this linter configuration.

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I8f38fbc315836a19a094d0d3e986758b9313f163
2023-04-17 18:38:24 -04:00
Joe Tsai
ff1b35ec6c net/connstats: exclude traffic with internal Tailscale service (#7904)
Exclude traffic with 100.100.100.100 (for IPv4) and
with fd7a:115c:a1e0::53 (for IPv6) since this traffic with the
Tailscale service running locally on the node.
This traffic never left the node.

It also happens to be a high volume amount of traffic since
DNS requests occur over UDP with each request coming from a
unique port, thus resulting in many discrete traffic flows.

Fixes tailscale/corp#10554

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2023-04-17 14:24:29 -07:00
Mihai Parparita
9a655a1d58 net/dnsfallback: more explicitly pass through logf function
Redoes the approach from #5550 and #7539 to explicitly pass in the logf
function, instead of having global state that can be overridden.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2023-04-17 12:06:23 -07:00
dependabot[bot]
28cb1221ba .github: Bump actions/setup-go from 3 to 4
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 3 to 4.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-04-17 11:51:30 -07:00
Brad Fitzpatrick
d5a870b4dc wgengine/monitor: add --monitor-duration flag to opt-in TestMonitorMode
TestMonitorMode skips by default, without the --monitor flag, and then
it previously ran forever. This adds an option --monitor-duration flag
that defaults to zero (run forever) but if non-zero bounds how long
the tests runs. This means you can then also use e.g. `go test
--cpuprofile` and capture a CPU/mem profile for a minute or two.

Updates #7621

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-17 11:14:40 -07:00
Brad Fitzpatrick
162488a775 net/interfaces: cache "home" router lookup on big Linux routers
This is a continuation of the earlier 2a67beaacf but more aggressive;
this now remembers that we failed to find the "home" router IP so we
don't try again later on the next call.

Updates #7621

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-17 10:51:21 -07:00
James Tucker
c5150eae67 net/netcheck: reenable TestNodeAddrResolve on Windows
Updates #7876

Co-authored-by: Andrew Dunham <andrew@du.nham.ca>
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Signed-off-by: James Tucker <james@tailscale.com>
Change-Id: Idb2e6cc2edf6ca123b751d6c8f8729b0cba86023
2023-04-17 12:41:56 -04:00
Andrew Dunham
80b138f0df wgengine/magicsock: keep advertising endpoints after we stop discovering them
Previously, when updating endpoints we would immediately stop
advertising any endpoint that wasn't discovered during
determineEndpoints. This could result in, for example, a case where we
performed an incremental netcheck, didn't get any of our three STUN
packets back, and then dropped our STUN endpoint from the set of
advertised endpoints... which would result in clients falling back to a
DERP connection until the next call to determineEndpoints.

Instead, let's cache endpoints that we've discovered and continue
reporting them to clients until a timeout expires. In the above case
where we temporarily don't have a discovered STUN endpoint, we would
continue reporting the old value, then re-discover the STUN endpoint
again and continue reporting it as normal, so clients never see a
withdrawal.

Updates tailscale/coral#108

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I42de72e7418ab328a6c732bdefc74549708cf8b9
2023-04-17 11:26:02 -04:00
Brad Fitzpatrick
4b49ca4a12 wgengine/magicsock: update comments on what implements conn.Bind
The comment still said *magicsock.Conn implemented wireguard-go conn.Bind.
That wasn't accurate anymore.

A doc #cleanup.

Change-Id: I7fd003b939497889cc81147bfb937b93e4f6865c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-16 09:07:13 -07:00
Brad Fitzpatrick
10f1c90f4d wgengine/magicsock, types/nettype, etc: finish ReadFromUDPAddrPort netip migration
So we're staying within the netip.Addr/AddrPort consistently and
avoiding allocs/conversions to the legacy net addr types.

Updates #5162

Change-Id: I59feba60d3de39f773e68292d759766bac98c917
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-15 13:40:15 -07:00
Brad Fitzpatrick
29f7df9d8f wgengine/magicsock, etc: remove mostly unused WriteTo methods
Updates #2331
Updates #5162

Change-Id: I8291884425481eeaedde38a54adfd8ed7292a497
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-15 08:32:11 -07:00
Brad Fitzpatrick
83c41f3697 net/netcheck: remove unused method from interface
Updates #2331
Updates #5162

Change-Id: I77ff956c2d59bde611d47523659a07afb4a6da2d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-04-15 07:55:43 -07:00
James Tucker
20f17d6e7b wgengine/magicsock: reenable magicsock tests on Windows
These tests are passing locally and on CI. They had failed earlier in
the day when first fixing up CI, and it is not immediately clear why. I
have cycled IPv6 support locally, but this should not have a substantial
effect.

Updates #7876

Signed-off-by: James Tucker <jftucker@gmail.com>
2023-04-14 22:53:53 -07:00
James Tucker
bd0c32ca21 tsnet: disable TestLoopbackSOCKS5 on Windows
This test is not regularly passing on CI, but seems to pass reliably
locally. Needs deeper debugging.

Updates #7876

Signed-off-by: James Tucker <jftucker@gmail.com>
2023-04-14 22:28:04 -07:00
517 changed files with 39544 additions and 13177 deletions

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

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

View File

@@ -0,0 +1,17 @@
on:
workflow_dispatch:
push:
tags:
- '^v[0-9]+\.[0-9]*[02468]+\.[0-9]+$'
jobs:
publish:
runs-on: "ubuntu-latest"
permissions:
id-token: "write"
contents: "read"
steps:
- uses: "actions/checkout@v3"
- uses: "DeterminateSystems/nix-installer-action@main"
- uses: "DeterminateSystems/flakehub-push@main"
with:
visibility: "public"

View File

@@ -50,11 +50,11 @@ jobs:
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 #v4.2.4
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: License Updater <noreply@tailscale.com>
committer: License Updater <noreply@tailscale.com>
author: License Updater <noreply+license-updater@tailscale.com>
committer: License Updater <noreply+license-updater@tailscale.com>
branch: licenses/cli
commit-message: "licenses: update tailscale{,d} licenses"
title: "licenses: update tailscale{,d} licenses"

40
.github/workflows/golangci-lint.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: golangci-lint
on:
# For now, only lint pull requests, not the main branches.
pull_request:
# TODO(andrew): enable for main branch after an initial waiting period.
#push:
# branches:
# - main
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version-file: go.mod
cache: false
- name: golangci-lint
# Note: this is the 'v3' tag as of 2023-04-17
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
with:
version: v1.52.2
# Show only new issues if it's a pull request.
only-new-issues: true

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

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

102
.github/workflows/installer.yml vendored Normal file
View File

@@ -0,0 +1,102 @@
name: test installer.sh
on:
push:
branches:
- "main"
paths:
- scripts/installer.sh
pull_request:
branches:
- "*"
paths:
- scripts/installer.sh
jobs:
test:
strategy:
# Don't abort the entire matrix if one element fails.
fail-fast: false
# Don't start all of these at once, which could saturate Github workers.
max-parallel: 4
matrix:
image:
# This is a list of Docker images against which we test our installer.
# If you find that some of these no longer exist, please feel free
# to remove them from the list.
# When adding new images, please only use official ones.
- "debian:oldstable-slim"
- "debian:stable-slim"
- "debian:testing-slim"
- "debian:sid-slim"
- "ubuntu:18.04"
- "ubuntu:20.04"
- "ubuntu:22.04"
- "ubuntu:22.10"
- "ubuntu:23.04"
- "elementary/docker:stable"
- "elementary/docker:unstable"
- "parrotsec/core:lts-amd64"
- "parrotsec/core:latest"
- "kalilinux/kali-rolling"
- "kalilinux/kali-dev"
- "oraclelinux:9"
- "oraclelinux:8"
- "fedora:latest"
- "rockylinux:8.7"
- "rockylinux:9"
- "amazonlinux:latest"
- "opensuse/leap:latest"
- "opensuse/tumbleweed:latest"
- "archlinux:latest"
- "alpine:3.14"
- "alpine:latest"
- "alpine:edge"
deps:
# Run all images installing curl as a dependency.
- curl
include:
# Check a few images with wget rather than curl.
- { image: "debian:oldstable-slim", deps: "wget" }
- { image: "debian:sid-slim", deps: "wget" }
- { image: "ubuntu:23.04", deps: "wget" }
# Ubuntu 16.04 also needs apt-transport-https installed.
- { image: "ubuntu:16.04", deps: "curl apt-transport-https" }
- { image: "ubuntu:16.04", deps: "wget apt-transport-https" }
runs-on: ubuntu-latest
container:
image: ${{ matrix.image }}
options: --user root
steps:
- name: install dependencies (yum)
# tar and gzip are needed by the actions/checkout below.
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}
if: |
contains(matrix.image, 'centos')
|| contains(matrix.image, 'oraclelinux')
|| contains(matrix.image, 'fedora')
|| contains(matrix.image, 'amazonlinux')
- name: install dependencies (zypper)
# tar and gzip are needed by the actions/checkout below.
run: zypper --non-interactive install tar gzip ${{ matrix.deps }}
if: contains(matrix.image, 'opensuse')
- name: install dependencies (apt-get)
run: |
apt-get update
apt-get install -y ${{ matrix.deps }}
if: |
contains(matrix.image, 'debian')
|| contains(matrix.image, 'ubuntu')
|| contains(matrix.image, 'elementary')
|| contains(matrix.image, 'parrotsec')
|| contains(matrix.image, 'kalilinux')
- name: checkout
uses: actions/checkout@v3
- name: run installer
run: scripts/installer.sh
# Package installation can fail in docker because systemd is not running
# as PID 1, so ignore errors at this step. The real check is the
# `tailscale --version` command below.
continue-on-error: true
- name: check tailscale version
run: tailscale --version

View File

@@ -65,8 +65,9 @@ jobs:
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ 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
run: ./tool/go build ${{matrix.buildflags}} ./...
@@ -89,7 +90,11 @@ jobs:
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: test all
run: ./tool/go test ${{matrix.buildflags}} -exec=/tmp/testwrapper -bench=. -benchtime=1x ./...
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
env:
GOARCH: ${{ matrix.goarch }}
- name: bench all
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$
env:
GOARCH: ${{ matrix.goarch }}
- name: check that no tracked files changed
@@ -113,7 +118,7 @@ jobs:
uses: actions/checkout@v3
- name: Install Go
uses: actions/setup-go@v3
uses: actions/setup-go@v4
with:
go-version-file: go.mod
cache: false
@@ -131,14 +136,15 @@ jobs:
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-go-2-
- name: test
# Don't use -bench=. -benchtime=1x.
# Somewhere in the layers (powershell?)
# the equals signs cause great confusion.
run: go test -bench . -benchtime 1x ./...
run: go run ./cmd/testwrapper ./... -bench . -benchtime 1x
vm:
runs-on: ["self-hosted", "linux", "vm"]
@@ -206,8 +212,9 @@ jobs:
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
- name: build all
run: ./tool/go build ./cmd/...
@@ -271,8 +278,9 @@ jobs:
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-go-2-
- name: build tsconnect client
run: ./tool/go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli
@@ -450,7 +458,7 @@ jobs:
# By having the job always run, but skipping its only step as needed, we
# let the CI output collapse nicely in PRs.
if: failure() && github.event_name == 'push'
uses: ruby/action-slack@v3.0.0
uses: ruby/action-slack@v3.2.1
with:
payload: |
{

View File

@@ -35,11 +35,11 @@ jobs:
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 #v4.2.4
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: Flakes Updater <noreply@tailscale.com>
committer: Flakes Updater <noreply@tailscale.com>
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
branch: flakes
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
title: "go.mod.sri: update SRI hash for go.mod changes"

5
.gitignore vendored
View File

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

61
.golangci.yml Normal file
View File

@@ -0,0 +1,61 @@
linters:
# Don't enable any linters by default; just the ones that we explicitly
# enable in the list below.
disable-all: true
enable:
- bidichk
- gofmt
- goimports
- misspell
- revive
# Configuration for how we run golangci-lint
run:
timeout: 5m
issues:
# Excluding configuration per-path, per-linter, per-text and per-source
exclude-rules:
# These are forks of an upstream package and thus are exempt from stylistic
# changes that would make pulling in upstream changes harder.
- path: tempfork/.*\.go
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
- path: util/singleflight/.*\.go
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
# Per-linter settings are contained in this top-level key
linters-settings:
# Enable all rules by default; we don't use invisible unicode runes.
bidichk:
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
goimports:
misspell:
revive:
enable-all-rules: false
ignore-generated-header: true
rules:
- name: atomic
- name: context-keys-type
- name: defer
arguments: [[
# Calling 'recover' at the time a defer is registered (i.e. "defer recover()") has no effect.
"immediate-recover",
# Calling 'recover' outside of a deferred function has no effect
"recover",
# Returning values from a deferred function has no effect
"return",
]]
- name: duplicated-imports
- name: errorf
- name: string-of-int
- name: time-equal
- name: unconditional-recursion
- name: useless-break
- name: waitgroup-by-value

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
/tailcfg/ @tailscale/control-protocol-owners

View File

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

View File

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

View File

@@ -48,11 +48,10 @@ staticcheck: ## Run staticcheck.io checks
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork)
spk: ## Build synology package for ${SYNO_ARCH} architecture and ${SYNO_DSM} DSM version
PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o tailscale.spk --source=. --goarch=${SYNO_ARCH} --dsm-version=${SYNO_DSM}
./tool/go run ./cmd/dist build synology/dsm${SYNO_DSM}/${SYNO_ARCH}
spkall: ## Build synology packages for all architectures and DSM versions
mkdir -p spks
PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o spks --source=. --goarch=all --dsm-version=all
./tool/go run ./cmd/dist build synology
pushspk: spk ## Push and install synology package on ${SYNO_HOST} host
echo "Pushing SPK to root@${SYNO_HOST} (env var SYNO_HOST) ..."

View File

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

View File

@@ -1 +1 @@
1.39.0
1.49.0

42
api.md
View File

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

View File

@@ -30,7 +30,7 @@ func TestDoesNotOverwriteIrregularFiles(t *testing.T) {
}
// The least troublesome thing to make that is not a file is a unix socket.
// Making a null device sadly requries root.
// Making a null device sadly requires root.
l, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
t.Fatal(err)

View File

@@ -16,7 +16,7 @@ if [ -n "${TS_USE_TOOLCHAIN:-}" ]; then
go="./tool/go"
fi
eval `GOOS=$($go env GOHOSTOS) GOARCH=$($go env GOHOSTARCH) $go run ./cmd/mkversion`
eval `CGO_ENABLED=0 GOOS=$($go env GOHOSTOS) GOARCH=$($go env GOHOSTARCH) $go run ./cmd/mkversion`
if [ "$1" = "shellvars" ]; then
cat <<EOF
@@ -49,4 +49,4 @@ while [ "$#" -gt 1 ]; do
esac
done
exec ./tool/go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"
exec $go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"

View File

@@ -150,8 +150,9 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
// ACLTestFailureSummary specifies the JSON format sent to the
// JavaScript client to be rendered in the HTML.
type ACLTestFailureSummary struct {
User string `json:"user"`
Errors []string `json:"errors"`
User string `json:"user,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.
@@ -436,7 +437,7 @@ func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (test
}
}()
tests := []ACLTest{ACLTest{User: source, Allow: []string{dest}}}
tests := []ACLTest{{User: source, Allow: []string{dest}}}
postData, err := json.Marshal(tests)
if err != nil {
return nil, err

View File

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

View File

@@ -4,12 +4,13 @@
package apitype
type DNSConfig struct {
Resolvers []DNSResolver `json:"resolvers"`
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
Routes map[string][]DNSResolver `json:"routes"`
Domains []string `json:"domains"`
Nameservers []string `json:"nameservers"`
Proxied bool `json:"proxied"`
Resolvers []DNSResolver `json:"resolvers"`
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
Routes map[string][]DNSResolver `json:"routes"`
Domains []string `json:"domains"`
Nameservers []string `json:"nameservers"`
Proxied bool `json:"proxied"`
TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"`
}
type DNSResolver struct {

View File

@@ -12,7 +12,6 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"tailscale.com/types/opt"
)
@@ -213,8 +212,20 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
// AuthorizeDevice marks a device as authorized.
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
return c.SetAuthorized(ctx, deviceID, true)
}
// SetAuthorized marks a device as authorized or not.
func (c *Client) SetAuthorized(ctx context.Context, deviceID string, authorized bool) error {
params := &struct {
Authorized bool `json:"authorized"`
}{Authorized: authorized}
data, err := json.Marshal(params)
if err != nil {
return err
}
path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.baseURL(), url.PathEscape(deviceID))
req, err := http.NewRequestWithContext(ctx, "POST", path, strings.NewReader(`{"authorized":true}`))
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
if err != nil {
return err
}

View File

@@ -63,7 +63,7 @@ func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, er
return b, nil
}
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData interface{}) ([]byte, error) {
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
data, err := json.Marshal(&postData)
if err != nil {

View File

@@ -68,12 +68,32 @@ func (c *Client) Keys(ctx context.Context) ([]string, error) {
}
// CreateKey creates a new key for the current user. Currently, only auth keys
// can be created. Returns the key itself, which cannot be retrieved again
// can be created. It returns the secret key itself, which cannot be retrieved again
// later, and the key metadata.
func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (string, *Key, error) {
//
// To create a key with a specific expiry, use CreateKeyWithExpiry.
func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (keySecret string, keyMeta *Key, _ error) {
return c.CreateKeyWithExpiry(ctx, caps, 0)
}
// CreateKeyWithExpiry is like CreateKey, but allows specifying a expiration time.
//
// The time is truncated to a whole number of seconds. If zero, that means no expiration.
func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, expiry time.Duration) (keySecret string, keyMeta *Key, _ error) {
// convert expirySeconds to an int64 (seconds)
expirySeconds := int64(expiry.Seconds())
if expirySeconds < 0 {
return "", nil, fmt.Errorf("expiry must be positive")
}
if expirySeconds == 0 && expiry != 0 {
return "", nil, fmt.Errorf("non-zero expiry must be at least one second")
}
keyRequest := struct {
Capabilities KeyCapabilities `json:"capabilities"`
}{caps}
Capabilities KeyCapabilities `json:"capabilities"`
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
}{caps, int64(expirySeconds)}
bs, err := json.Marshal(keyRequest)
if err != nil {
return "", nil, err

View File

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

View File

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

41
client/web/api.go Normal file
View File

@@ -0,0 +1,41 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"net/http"
"strings"
"github.com/gorilla/csrf"
"tailscale.com/util/httpm"
)
type api struct {
s *Server
}
// ServeHTTP serves requests for the web client api.
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
// which protects the handler using gorilla csrf.
func (a *api) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
user, err := authorize(w, r)
if err != nil {
return
}
path := strings.TrimPrefix(r.URL.Path, "/api")
switch path {
case "/data":
switch r.Method {
case httpm.GET:
a.s.serveGetNodeDataJSON(w, r, user)
case httpm.POST:
a.s.servePostNodeUpdate(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
}

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

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

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

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

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

@@ -0,0 +1,44 @@
{
"name": "webclient",
"version": "0.0.1",
"license": "BSD-3-Clause",
"engines": {
"node": "18.16.1",
"yarn": "1.22.19"
},
"private": true,
"dependencies": {
"classnames": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.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",
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
"typescript": "^4.7.4",
"vite": "^4.3.9",
"vite-plugin-rewrite-all": "^1.0.1",
"vite-plugin-svgr": "^3.2.0",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^0.32.0"
},
"scripts": {
"build": "vite build",
"start": "vite",
"lint": "tsc --noEmit",
"test": "vitest",
"format": "prettier --write 'src/**/*.{ts,tsx}'",
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
},
"prettier": {
"semi": false,
"printWidth": 80
}
}

View File

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

32
client/web/src/api.ts Normal file
View File

@@ -0,0 +1,32 @@
let csrfToken: string
// apiFetch wraps the standard JS fetch function
// with csrf header management.
export function apiFetch(
input: RequestInfo | URL,
init?: RequestInit | undefined
): Promise<Response> {
return fetch(input, {
...init,
headers: withCsrfToken(init?.headers),
}).then((r) => {
updateCsrfToken(r)
if (!r.ok) {
return r.text().then((err) => {
throw new Error(err)
})
}
return r
})
}
function withCsrfToken(h?: HeadersInit): HeadersInit {
return { ...h, "X-CSRF-Token": csrfToken }
}
function updateCsrfToken(r: Response) {
const tok = r.headers.get("X-CSRF-Token")
if (tok) {
csrfToken = tok
}
}

View File

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

View File

@@ -0,0 +1,291 @@
import cx from "classnames"
import React from "react"
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,
updateNode,
}: {
data: NodeData
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={() => updateNode({ ForceLogout: true })}
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: { data: NodeData }) {
const { data } = props
return (
<footer className="container max-w-lg mx-auto text-center">
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={data.LicensesURL}
>
Open Source Licenses
</a>
</footer>
)
}

View File

@@ -0,0 +1,123 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch } from "src/api"
export type NodeData = {
Profile: UserProfile
Status: string
DeviceName: string
IP: string
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
TUNMode: boolean
IsSynology: boolean
DSMVersion: number
IsUnraid: boolean
UnraidToken: string
IPNVersion: string
}
export type UserProfile = {
LoginName: string
DisplayName: string
ProfilePicURL: string
}
export type NodeUpdate = {
AdvertiseRoutes?: string
AdvertiseExitNode?: boolean
Reauthenticate?: boolean
ForceLogout?: boolean
}
// useNodeData returns basic data about the current node.
export default function useNodeData() {
const [data, setData] = useState<NodeData>()
const [isPosting, setIsPosting] = useState<boolean>(false)
const fetchNodeData = useCallback(() => {
apiFetch("/api/data")
.then((r) => r.json())
.then((data) => setData(data))
.catch((error) => console.error(error))
}, [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
}
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,
}
const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams({ up: "true" })
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const search = nextParams.toString()
const url = `/api/data${search ? `?${search}` : ""}`
var body, contentType: string
if (data.IsUnraid) {
const params = new URLSearchParams()
params.append("csrf_token", data.UnraidToken)
params.append("ts_data", JSON.stringify(update))
body = params.toString()
contentType = "application/x-www-form-urlencoded;charset=UTF-8"
} else {
body = JSON.stringify(update)
contentType = "application/json"
}
apiFetch(url, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": contentType },
body: body,
})
.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")
}
fetchNodeData()
})
.catch((err) => alert("Failed operation: " + err.message))
},
[data]
)
useEffect(
fetchNodeData,
// Initial data load.
[]
)
return { data, updateNode, isPosting }
}

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

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

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

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

View File

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

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

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

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

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

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

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

View File

@@ -116,10 +116,12 @@
<a class="text-xs text-gray-500 hover:text-gray-600" href="{{ .LicensesURL }}">Open Source Licenses</a>
</footer>
<script>(function () {
const advertiseExitNode = {{.AdvertiseExitNode}};
const advertiseExitNode = {{ .AdvertiseExitNode }};
const isUnraid = {{ .IsUnraid }};
const unraidCsrfToken = "{{ .UnraidToken }}";
let fetchingUrl = false;
var data = {
AdvertiseRoutes: "{{.AdvertiseRoutes}}",
AdvertiseRoutes: "{{ .AdvertiseRoutes }}",
AdvertiseExitNode: advertiseExitNode,
Reauthenticate: false,
ForceLogout: false
@@ -141,15 +143,27 @@ function postData(e) {
}
const nextUrl = new URL(window.location);
nextUrl.search = nextParams.toString()
const url = nextUrl.toString();
let body = JSON.stringify(data);
let contentType = "application/json";
if (isUnraid) {
const params = new URLSearchParams();
params.append("csrf_token", unraidCsrfToken);
params.append("ts_data", JSON.stringify(data));
body = params.toString();
contentType = "application/x-www-form-urlencoded;charset=UTF-8";
}
const url = nextUrl.toString();
fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"Content-Type": contentType,
},
body: JSON.stringify(data)
body: body
}).then(res => res.json()).then(res => {
fetchingUrl = false;
const err = res["error"];
@@ -158,7 +172,11 @@ function postData(e) {
}
const url = res["url"];
if (url) {
document.location.href = url;
if(isUnraid) {
window.open(url, "_blank");
} else {
document.location.href = url;
}
} else {
location.reload();
}

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

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

1818
client/web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

1009
clientupdate/clientupdate.go Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,338 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package distsign implements signature and validation of arbitrary
// distributable files.
//
// There are 3 parties in this exchange:
// - builder, which creates files, signs them with signing keys and publishes
// to server
// - server, which distributes public signing keys, files and signatures
// - client, which downloads files and signatures from server, and validates
// the signatures
//
// There are 2 types of keys:
// - signing keys, that sign individual distributable files on the builder
// - root keys, that sign signing keys and are kept offline
//
// root keys -(sign)-> signing keys -(sign)-> files
//
// All keys are asymmetric Ed25519 key pairs.
//
// The server serves static files under some known prefix. The kinds of files are:
// - distsign.pub - bundle of PEM-encoded public signing keys
// - distsign.pub.sig - signature of distsign.pub using one of the root keys
// - $file - any distributable file
// - $file.sig - signature of $file using any of the signing keys
//
// The root public keys are baked into the client software at compile time.
// These keys are long-lived and prove the validity of current signing keys
// from distsign.pub. To rotate root keys, a new client release must be
// published, they are not rotated dynamically. There are multiple root keys in
// different locations specifically to allow this rotation without using the
// discarded root key for any new signatures.
//
// The signing public keys are fetched by the client dynamically before every
// download and can be rotated more readily, assuming that most deployed
// clients trust the root keys used to issue fresh signing keys.
package distsign
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
"encoding/pem"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"os"
"github.com/hdevalence/ed25519consensus"
"golang.org/x/crypto/blake2s"
)
const (
pemTypePrivate = "PRIVATE KEY"
pemTypePublic = "PUBLIC KEY"
downloadSizeLimit = 1 << 29 // 512MB
signingKeysSizeLimit = 1 << 20 // 1MB
signatureSizeLimit = ed25519.SignatureSize
)
// GenerateKey generates a new key pair and encodes it as PEM.
func GenerateKey() (priv, pub []byte, err error) {
pub, priv, err = ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
return pem.EncodeToMemory(&pem.Block{
Type: pemTypePrivate,
Bytes: []byte(priv),
}), pem.EncodeToMemory(&pem.Block{
Type: pemTypePublic,
Bytes: []byte(pub),
}), nil
}
// RootKey is a root key Signer used to sign signing keys.
type RootKey Signer
// SignSigningKeys signs the bundle of public signing keys. The bundle must be
// a sequence of PEM blocks joined with newlines.
func (s *RootKey) SignSigningKeys(pubBundle []byte) ([]byte, error) {
return s.Sign(nil, pubBundle, crypto.Hash(0))
}
// SigningKey is a signing key Signer used to sign packages.
type SigningKey Signer
// SignPackageHash signs the hash and the length of a package. Use PackageHash
// to compute the inputs.
func (s SigningKey) SignPackageHash(hash []byte, len int64) ([]byte, error) {
if len <= 0 {
return nil, fmt.Errorf("package length must be positive, got %d", len)
}
msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
return s.Sign(nil, msg, crypto.Hash(0))
}
// PackageHash is a hash.Hash that counts the number of bytes written. Use it
// to get the hash and length inputs to SigningKey.SignPackageHash.
type PackageHash struct {
hash.Hash
len int64
}
// NewPackageHash returns an initialized PackageHash using BLAKE2s.
func NewPackageHash() *PackageHash {
h, err := blake2s.New256(nil)
if err != nil {
// Should never happen with a nil key passed to blake2s.
panic(err)
}
return &PackageHash{Hash: h}
}
func (ph *PackageHash) Write(b []byte) (int, error) {
ph.len += int64(len(b))
return ph.Hash.Write(b)
}
// Reset the PackageHash to its initial state.
func (ph *PackageHash) Reset() {
ph.len = 0
ph.Hash.Reset()
}
// Len returns the total number of bytes written.
func (ph *PackageHash) Len() int64 { return ph.len }
// Signer is crypto.Signer using a single key (root or signing).
type Signer struct {
crypto.Signer
}
// NewSigner parses the PEM-encoded private key stored in the file named
// privKeyPath and creates a Signer for it. The key is expected to be in the
// same format as returned by GenerateKey.
func NewSigner(privKeyPath string) (Signer, error) {
raw, err := os.ReadFile(privKeyPath)
if err != nil {
return Signer{}, err
}
k, err := parsePrivateKey(raw)
if err != nil {
return Signer{}, fmt.Errorf("failed to parse %q: %w", privKeyPath, err)
}
return Signer{Signer: k}, nil
}
// Client downloads and validates files from a distribution server.
type Client struct {
roots []ed25519.PublicKey
pkgsAddr *url.URL
}
// NewClient returns a new client for distribution server located at pkgsAddr,
// and uses embedded root keys from the roots/ subdirectory of this package.
func NewClient(pkgsAddr string) (*Client, error) {
u, err := url.Parse(pkgsAddr)
if err != nil {
return nil, fmt.Errorf("invalid pkgsAddr %q: %w", pkgsAddr, err)
}
return &Client{roots: roots(), pkgsAddr: u}, nil
}
func (c *Client) url(path string) string {
return c.pkgsAddr.JoinPath(path).String()
}
// Download fetches a file at path srcPath from pkgsAddr passed in NewClient.
// The file is downloaded to dstPath and its signature is validated using the
// embedded root keys. Download returns an error if anything goes wrong with
// the actual file download or with signature validation.
func (c *Client) Download(srcPath, dstPath string) error {
// Always fetch a fresh signing key.
sigPub, err := c.signingKeys()
if err != nil {
return err
}
srcURL := c.url(srcPath)
sigURL := srcURL + ".sig"
dstPathUnverified := dstPath + ".unverified"
hash, len, err := download(srcURL, dstPathUnverified, downloadSizeLimit)
if err != nil {
return err
}
sig, err := fetch(sigURL, signatureSizeLimit)
if err != nil {
// Best-effort clean up of downloaded package.
os.Remove(dstPathUnverified)
return err
}
msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
if !verifyAny(sigPub, msg, sig) {
// Best-effort clean up of downloaded package.
os.Remove(dstPathUnverified)
return fmt.Errorf("signature %q for key %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, srcURL)
}
if err := os.Rename(dstPathUnverified, dstPath); err != nil {
return fmt.Errorf("failed to move %q to %q after signature validation", dstPathUnverified, dstPath)
}
return nil
}
// signingKeys fetches current signing keys from the server and validates them
// against the roots. Should be called before validation of any downloaded file
// to get the fresh keys.
func (c *Client) signingKeys() ([]ed25519.PublicKey, error) {
keyURL := c.url("distsign.pub")
sigURL := keyURL + ".sig"
raw, err := fetch(keyURL, signingKeysSizeLimit)
if err != nil {
return nil, err
}
sig, err := fetch(sigURL, signatureSizeLimit)
if err != nil {
return nil, err
}
if !verifyAny(c.roots, raw, sig) {
return nil, fmt.Errorf("signature %q for key %q does not validate with any known root key; either you are under attack, or running a very old version of Tailscale with outdated root keys", sigURL, keyURL)
}
// Parse the bundle of public signing keys.
var keys []ed25519.PublicKey
for len(raw) > 0 {
pub, rest, err := parsePublicKey(raw)
if err != nil {
return nil, err
}
keys = append(keys, pub)
raw = rest
}
if len(keys) == 0 {
return nil, fmt.Errorf("no signing keys found at %q", keyURL)
}
return keys, nil
}
// fetch reads the response body from url into memory, up to limit bytes.
func fetch(url string, limit int64) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, limit))
}
// download writes the response body of url into a local file at dst, up to
// limit bytes. On success, the returned value is a BLAKE2s hash of the file.
func download(url, dst string, limit int64) ([]byte, int64, error) {
resp, err := http.Get(url)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
h := NewPackageHash()
r := io.TeeReader(io.LimitReader(resp.Body, limit), h)
f, err := os.Create(dst)
if err != nil {
return nil, 0, err
}
defer f.Close()
if _, err := io.Copy(f, r); err != nil {
return nil, 0, err
}
if err := f.Close(); err != nil {
return nil, 0, err
}
return h.Sum(nil), h.Len(), nil
}
func parsePrivateKey(data []byte) (ed25519.PrivateKey, error) {
b, rest := pem.Decode(data)
if b == nil {
return nil, errors.New("failed to decode PEM data")
}
if len(rest) > 0 {
return nil, errors.New("trailing PEM data")
}
if b.Type != pemTypePrivate {
return nil, fmt.Errorf("PEM type is %q, want %q", b.Type, pemTypePrivate)
}
if len(b.Bytes) != ed25519.PrivateKeySize {
return nil, errors.New("private key has incorrect length for an Ed25519 private key")
}
return ed25519.PrivateKey(b.Bytes), nil
}
func parseSinglePublicKey(data []byte) (ed25519.PublicKey, error) {
pub, rest, err := parsePublicKey(data)
if err != nil {
return nil, err
}
if len(rest) > 0 {
return nil, errors.New("trailing PEM data")
}
return pub, err
}
func parsePublicKey(data []byte) (pub ed25519.PublicKey, rest []byte, retErr error) {
b, rest := pem.Decode(data)
if b == nil {
return nil, nil, errors.New("failed to decode PEM data")
}
if b.Type != pemTypePublic {
return nil, nil, fmt.Errorf("PEM type is %q, want %q", b.Type, pemTypePublic)
}
if len(b.Bytes) != ed25519.PublicKeySize {
return nil, nil, errors.New("public key has incorrect length for an Ed25519 public key")
}
return ed25519.PublicKey(b.Bytes), rest, nil
}
// verifyAny verifies whether sig is valid for msg using any of the keys.
// verifyAny will panic of any of the keys have the wrong size for Ed25519.
func verifyAny(keys []ed25519.PublicKey, msg, sig []byte) bool {
for _, k := range keys {
if ed25519consensus.Verify(k, msg, sig) {
return true
}
}
return false
}

View File

@@ -0,0 +1,347 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import (
"bytes"
"crypto/ed25519"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/blake2s"
)
func TestDownload(t *testing.T) {
srv := newTestServer(t)
c := srv.client(t)
tests := []struct {
desc string
before func(*testing.T)
src string
want []byte
wantErr bool
}{
{
desc: "missing file",
before: func(*testing.T) {},
src: "hello",
wantErr: true,
},
{
desc: "success",
before: func(*testing.T) {
srv.addSigned("hello", []byte("world"))
},
src: "hello",
want: []byte("world"),
},
{
desc: "no signature",
before: func(*testing.T) {
srv.add("hello", []byte("world"))
},
src: "hello",
wantErr: true,
},
{
desc: "bad signature",
before: func(*testing.T) {
srv.add("hello", []byte("world"))
srv.add("hello.sig", []byte("potato"))
},
src: "hello",
wantErr: true,
},
{
desc: "signed with untrusted key",
before: func(t *testing.T) {
srv.add("hello", []byte("world"))
srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world")))
},
src: "hello",
wantErr: true,
},
{
desc: "signed with root key",
before: func(t *testing.T) {
srv.add("hello", []byte("world"))
srv.add("hello.sig", srv.roots[0].sign([]byte("world")))
},
src: "hello",
wantErr: true,
},
{
desc: "bad signing key signature",
before: func(t *testing.T) {
srv.add("distsign.pub.sig", []byte("potato"))
srv.addSigned("hello", []byte("world"))
},
src: "hello",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
srv.reset()
tt.before(t)
dst := filepath.Join(t.TempDir(), tt.src)
t.Cleanup(func() {
os.Remove(dst)
})
err := c.Download(tt.src, dst)
if err != nil {
if tt.wantErr {
return
}
t.Fatalf("unexpected error from Download(%q): %v", tt.src, err)
}
if tt.wantErr {
t.Fatalf("Download(%q) succeeded, expected an error", tt.src)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(tt.want, got) {
t.Errorf("Download(%q): got %q, want %q", tt.src, got, tt.want)
}
})
}
}
func TestRotateRoot(t *testing.T) {
srv := newTestServer(t)
c1 := srv.client(t)
srv.addSigned("hello", []byte("world"))
if err := c1.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed on a fresh server: %v", err)
}
// Remove first root and replace it with a new key.
srv.roots = append(srv.roots[1:], newRootKeyPair(t))
// Old client can still download files because it still trusts the old
// root key.
if err := c1.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed after root rotation on old client: %v", err)
}
// New client should fail download because current signing key is signed by
// the revoked root that new client doesn't trust.
c2 := srv.client(t)
if err := c2.Download("hello", filepath.Join(t.TempDir(), "hello")); err == nil {
t.Fatalf("Download succeeded on new client, but signing key is signed with revoked root key")
}
// Re-sign signing key with another valid root that client still trusts.
srv.resignSigningKeys()
// Both old and new clients should now be able to download.
//
// Note: we don't need to re-sign the "hello" file because signing key
// didn't change (only signing key's signature).
if err := c1.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed after root rotation on old client with re-signed signing key: %v", err)
}
if err := c2.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed after root rotation on new client with re-signed signing key: %v", err)
}
}
func TestRotateSigning(t *testing.T) {
srv := newTestServer(t)
c := srv.client(t)
srv.addSigned("hello", []byte("world"))
if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed on a fresh server: %v", err)
}
// Replace signing key but don't publish it yet.
srv.sign = append(srv.sign, newSigningKeyPair(t))
if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed after new signing key added but before publishing it: %v", err)
}
// Publish new signing key bundle with both keys.
srv.resignSigningKeys()
if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed after new signing key was published: %v", err)
}
// Re-sign the "hello" file with new signing key.
srv.add("hello.sig", srv.sign[1].sign([]byte("world")))
if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed after re-signing with new signing key: %v", err)
}
// Drop the old signing key.
srv.sign = srv.sign[1:]
srv.resignSigningKeys()
if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed after removing old signing key: %v", err)
}
// Add another key and re-sign the file with it *before* publishing.
srv.sign = append(srv.sign, newSigningKeyPair(t))
srv.add("hello.sig", srv.sign[1].sign([]byte("world")))
if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err == nil {
t.Fatalf("Download succeeded when signed with a not-yet-published signing key")
}
// Fix this by publishing the new key.
srv.resignSigningKeys()
if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
t.Fatalf("Download failed after publishing new signing key: %v", err)
}
}
type testServer struct {
roots []rootKeyPair
sign []signingKeyPair
files map[string][]byte
srv *httptest.Server
}
func newTestServer(t *testing.T) *testServer {
var roots []rootKeyPair
for i := 0; i < 3; i++ {
roots = append(roots, newRootKeyPair(t))
}
ts := &testServer{
roots: roots,
sign: []signingKeyPair{newSigningKeyPair(t)},
}
ts.reset()
ts.srv = httptest.NewServer(ts)
t.Cleanup(ts.srv.Close)
return ts
}
func (s *testServer) client(t *testing.T) *Client {
roots := make([]ed25519.PublicKey, 0, len(s.roots))
for _, r := range s.roots {
pub, err := parseSinglePublicKey(r.pubRaw)
if err != nil {
t.Fatalf("parsePublicKey: %v", err)
}
roots = append(roots, pub)
}
u, err := url.Parse(s.srv.URL)
if err != nil {
t.Fatal(err)
}
return &Client{
roots: roots,
pkgsAddr: u,
}
}
func (s *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
data, ok := s.files[path]
if !ok {
http.NotFound(w, r)
return
}
w.Write(data)
}
func (s *testServer) addSigned(name string, data []byte) {
s.files[name] = data
s.files[name+".sig"] = s.sign[0].sign(data)
}
func (s *testServer) add(name string, data []byte) {
s.files[name] = data
}
func (s *testServer) reset() {
s.files = make(map[string][]byte)
s.resignSigningKeys()
}
func (s *testServer) resignSigningKeys() {
var pubs [][]byte
for _, k := range s.sign {
pubs = append(pubs, k.pubRaw)
}
bundle := bytes.Join(pubs, []byte("\n"))
sig := s.roots[0].sign(bundle)
s.files["distsign.pub"] = bundle
s.files["distsign.pub.sig"] = sig
}
type rootKeyPair struct {
*RootKey
keyPair
}
func newRootKeyPair(t *testing.T) rootKeyPair {
kp := newKeyPair(t)
priv, err := parsePrivateKey(kp.privRaw)
if err != nil {
t.Fatalf("parsePrivateKey: %v", err)
}
return rootKeyPair{
RootKey: &RootKey{Signer: priv},
keyPair: kp,
}
}
func (s rootKeyPair) sign(bundle []byte) []byte {
sig, err := s.SignSigningKeys(bundle)
if err != nil {
panic(err)
}
return sig
}
type signingKeyPair struct {
*SigningKey
keyPair
}
func newSigningKeyPair(t *testing.T) signingKeyPair {
kp := newKeyPair(t)
priv, err := parsePrivateKey(kp.privRaw)
if err != nil {
t.Fatalf("parsePrivateKey: %v", err)
}
return signingKeyPair{
SigningKey: &SigningKey{Signer: priv},
keyPair: kp,
}
}
func (s signingKeyPair) sign(blob []byte) []byte {
hash := blake2s.Sum256(blob)
sig, err := s.SignPackageHash(hash[:], int64(len(blob)))
if err != nil {
panic(err)
}
return sig
}
type keyPair struct {
privRaw []byte
pubRaw []byte
}
func newKeyPair(t *testing.T) keyPair {
privRaw, pubRaw, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
return keyPair{
privRaw: privRaw,
pubRaw: pubRaw,
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import (
"crypto/ed25519"
"embed"
"errors"
"fmt"
"path"
"path/filepath"
"sync"
)
//go:embed roots
var rootsFS embed.FS
var roots = sync.OnceValue(func() []ed25519.PublicKey {
roots, err := parseRoots()
if err != nil {
panic(err)
}
return roots
})
func parseRoots() ([]ed25519.PublicKey, error) {
files, err := rootsFS.ReadDir("roots")
if err != nil {
return nil, err
}
var keys []ed25519.PublicKey
for _, f := range files {
if !f.Type().IsRegular() {
continue
}
if filepath.Ext(f.Name()) != ".pub" {
continue
}
raw, err := rootsFS.ReadFile(path.Join("roots", f.Name()))
if err != nil {
return nil, err
}
key, err := parseSinglePublicKey(raw)
if err != nil {
return nil, fmt.Errorf("parsing root key %q: %w", f.Name(), err)
}
keys = append(keys, key)
}
if len(keys) == 0 {
return nil, errors.New("no embedded root keys, please check clientupdate/distsign/roots/")
}
return keys, nil
}

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
JNBgo4EFQ+DpRcESM2xU19xQWGffvLcmxtBMT4I+Qo0=
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import "testing"
func TestParseRoots(t *testing.T) {
roots, err := parseRoots()
if err != nil {
t.Fatal(err)
}
if len(roots) == 0 {
t.Error("parseRoots returned no root keys")
}
}

View File

@@ -126,11 +126,13 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("for i := range dst.%s {", fname)
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
writef("\tx := *src.%s[i]", fname)
writef("\tdst.%s[i] = &x", fname)
it.Import("tailscale.com/types/ptr")
writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname)
} else {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
}
} else if ft.Elem().String() == "encoding/json.RawMessage" {
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
} else {
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
}
@@ -143,41 +145,41 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("dst.%s = src.%s.Clone()", fname, fname)
continue
}
n := it.QualifiedName(ft.Elem())
it.Import("tailscale.com/types/ptr")
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = new(%s)", fname, n)
writef("\t*dst.%s = *src.%s", fname, fname)
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
if codegen.ContainsPointers(ft.Elem()) {
writef("\t" + `panic("TODO pointers in pointers")`)
}
writef("}")
case *types.Map:
elem := ft.Elem()
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
if sliceType, isSlice := elem.(*types.Slice); isSlice {
n := it.QualifiedName(sliceType.Elem())
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
writef("\tfor k := range src.%s {", fname)
// use zero-length slice instead of nil to ensure
// the key is always copied.
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
writef("\t}")
writef("}")
} else if codegen.ContainsPointers(elem) {
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
writef("\tfor k, v := range src.%s {", fname)
switch elem.(type) {
case *types.Pointer:
writef("\t\tdst.%s[k] = v.Clone()", fname)
default:
writef("\t\tv2 := v.Clone()")
writef("\t\tdst.%s[k] = *v2", fname)
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
}
writef("\t}")
writef("}")
} else {
writef("\tfor k, v := range src.%s {", fname)
writef("\t\tdst.%s[k] = v", fname)
writef("\t}")
it.Import("maps")
writef("\tdst.%s = maps.Clone(src.%s)", fname, fname)
}
writef("}")
default:
writef(`panic("TODO: %s (%T)")`, fname, ft)
}

View File

@@ -266,9 +266,9 @@ authLoop:
log.Fatalf("installing proxy rules: %v", err)
}
}
deviceInfo := []any{n.NetMap.SelfNode.StableID, n.NetMap.SelfNode.Name}
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); err != nil {
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name()); err != nil {
log.Fatalf("storing device ID in kube secret: %v", err)
}
}

View File

@@ -112,10 +112,10 @@ func TestContainerBoot(t *testing.T) {
runningNotify := &ipn.Notify{
State: ptr.To(ipn.Running),
NetMap: &netmap.NetworkMap{
SelfNode: &tailcfg.Node{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net",
},
}).View(),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
}
@@ -482,10 +482,10 @@ func TestContainerBoot(t *testing.T) {
Notify: &ipn.Notify{
State: ptr.To(ipn.Running),
NetMap: &netmap.NetworkMap{
SelfNode: &tailcfg.Node{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("newID"),
Name: "new-name.test.ts.net",
},
}).View(),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
},

View File

@@ -25,6 +25,7 @@ var (
dnsCache syncs.AtomicValue[dnsEntryMap]
dnsCacheBytes syncs.AtomicValue[[]byte] // of JSON
unpublishedDNSCache syncs.AtomicValue[dnsEntryMap]
bootstrapLookupMap syncs.Map[string, bool]
)
var (
@@ -35,6 +36,12 @@ var (
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
)
func init() {
expvar.Publish("counter_bootstrap_dns_queried_domains", expvar.Func(func() any {
return bootstrapLookupMap.Len()
}))
}
func refreshBootstrapDNSLoop() {
if *bootstrapDNS == "" && *unpublishedDNS == "" {
return
@@ -107,6 +114,7 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
// Try answering a query from our hidden map first
if q := r.URL.Query().Get("q"); q != "" {
bootstrapLookupMap.Store(q, true)
if ips, ok := unpublishedDNSCache.Load()[q]; ok && len(ips) > 0 {
unpublishedDNSHits.Add(1)

View File

@@ -98,6 +98,7 @@ func resetMetrics() {
publishedDNSMisses.Set(0)
unpublishedDNSHits.Set(0)
unpublishedDNSMisses.Set(0)
bootstrapLookupMap.Clear()
}
// Verify that we don't count an empty list in the unpublishedDNSCache as a
@@ -148,4 +149,17 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
t.Errorf("got misses=%d; want 0", v)
}
})
}
func TestLookupMetric(t *testing.T) {
d := []string{"a.io", "b.io", "c.io", "d.io", "e.io", "e.io", "e.io", "a.io"}
resetMetrics()
for _, q := range d {
_ = getBootstrapDNS(t, q)
}
// {"a.io": true, "b.io": true, "c.io": true, "d.io": true, "e.io": true}
if bootstrapLookupMap.Len() != 5 {
t.Errorf("bootstrapLookupMap.Len() want=5, got %v", bootstrapLookupMap.Len())
}
}

View File

@@ -81,7 +81,7 @@ func (m *manualCertManager) TLSConfig() *tls.Config {
return &tls.Config{
Certificates: nil,
NextProtos: []string{
"h2", "http/1.1", // enable HTTP/2
"http/1.1",
},
GetCertificate: m.getCertificate,
}

View File

@@ -3,25 +3,35 @@ 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
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
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/golang/protobuf/ptypes/timestamp from github.com/prometheus/client_model/go
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
github.com/klauspost/compress/flate from nhooyr.io/websocket
github.com/matttproud/golang_protobuf_extensions/pbutil from github.com/prometheus/common/expfmt
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
@@ -33,9 +43,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
LD github.com/prometheus/procfs from github.com/prometheus/client_golang/prometheus
LD github.com/prometheus/procfs/internal/fs from github.com/prometheus/procfs
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/wgengine/filter
go4.org/netipx from tailscale.com/wgengine/filter+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
google.golang.org/protobuf/encoding/prototext from github.com/golang/protobuf/proto+
google.golang.org/protobuf/encoding/protowire from github.com/golang/protobuf/proto+
@@ -64,7 +77,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
google.golang.org/protobuf/runtime/protoiface from github.com/golang/protobuf/proto+
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/golang/protobuf/ptypes/timestamp+
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
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
@@ -86,11 +99,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/interfaces from tailscale.com/net/netns+
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netknob from tailscale.com/net/netns
tailscale.com/net/netmon from tailscale.com/net/sockstats+
tailscale.com/net/netns from tailscale.com/derp/derphttp
tailscale.com/net/netutil from tailscale.com/client/tailscale
tailscale.com/net/packet from tailscale.com/wgengine/filter
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
tailscale.com/net/stun from tailscale.com/cmd/derper
L tailscale.com/net/tcpinfo from tailscale.com/derp
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
tailscale.com/net/tsaddr from tailscale.com/ipn+
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
@@ -101,7 +116,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime from tailscale.com/derp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
tailscale.com/tsweb from tailscale.com/cmd/derper
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
@@ -123,13 +139,15 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/cmpx from tailscale.com/cmd/derper+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
tailscale.com/util/mak from tailscale.com/syncs+
tailscale.com/util/multierr from tailscale.com/health
tailscale.com/util/set from tailscale.com/health
tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/set from tailscale.com/health+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/vizerror from tailscale.com/tsweb
@@ -151,8 +169,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/crypto/nacl/box from tailscale.com/types/key
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/exp/constraints from golang.org/x/exp/slices
golang.org/x/exp/slices from tailscale.com/net/tsaddr+
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http
@@ -175,6 +191,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/time/rate from tailscale.com/cmd/derper+
bufio from compress/flate+
bytes from bufio+
cmp from slices
compress/flate from compress/gzip+
compress/gzip from internal/profile+
container/list from crypto/tls+
@@ -223,6 +240,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
io/fs from crypto/x509+
io/ioutil from github.com/mitchellh/go-ps+
log from expvar+
log/internal from log
maps from tailscale.com/types/views+
math from compress/flate+
math/big from crypto/dsa+
math/bits from compress/flate+
@@ -250,6 +269,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
runtime/metrics from github.com/prometheus/client_golang/prometheus+
runtime/pprof from net/http/pprof
runtime/trace from net/http/pprof
slices from tailscale.com/ipn+
sort from compress/flate+
strconv from compress/flate+
strings from bufio+

View File

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

View File

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

27
cmd/dist/dist.go vendored
View File

@@ -13,15 +13,38 @@ import (
"tailscale.com/release/dist"
"tailscale.com/release/dist/cli"
"tailscale.com/release/dist/synology"
"tailscale.com/release/dist/unixpkgs"
)
func getTargets() ([]dist.Target, error) {
return unixpkgs.Targets(), nil
var synologyPackageCenter bool
func getTargets(signers unixpkgs.Signers) ([]dist.Target, error) {
var ret []dist.Target
ret = append(ret, unixpkgs.Targets(signers)...)
// Synology packages can be built either for sideloading, or for
// distribution by Synology in their package center. When
// distributed through the package center, apps can request
// additional permissions to use a tuntap interface and control
// the NAS's network stack, rather than be forced to run in
// userspace mode.
//
// Since only we can provide packages to Synology for
// distribution, we default to building the "sideload" variant of
// packages that we distribute on pkgs.tailscale.com.
ret = append(ret, synology.Targets(synologyPackageCenter)...)
return ret, nil
}
func main() {
cmd := cli.CLI(getTargets)
for _, subcmd := range cmd.Subcommands {
if subcmd.Name == "build" {
subcmd.FlagSet.BoolVar(&synologyPackageCenter, "synology-package-center", false, "build synology packages with extra metadata for the official package center")
}
}
if err := cmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil && !errors.Is(err, flag.ErrHelp) {
log.Fatal(err)
}

View File

@@ -16,6 +16,7 @@ import (
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/util/cmpx"
)
func main() {
@@ -29,9 +30,9 @@ func main() {
tags := flag.String("tags", "", "comma-separated list of tags to apply to the authkey")
flag.Parse()
clientId := os.Getenv("TS_API_CLIENT_ID")
clientID := os.Getenv("TS_API_CLIENT_ID")
clientSecret := os.Getenv("TS_API_CLIENT_SECRET")
if clientId == "" || clientSecret == "" {
if clientID == "" || clientSecret == "" {
log.Fatal("TS_API_CLIENT_ID and TS_API_CLIENT_SECRET must be set")
}
@@ -39,22 +40,19 @@ func main() {
log.Fatal("at least one tag must be specified")
}
baseUrl := os.Getenv("TS_BASE_URL")
if baseUrl == "" {
baseUrl = "https://api.tailscale.com"
}
baseURL := cmpx.Or(os.Getenv("TS_BASE_URL"), "https://api.tailscale.com")
credentials := clientcredentials.Config{
ClientID: clientId,
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: baseUrl + "/api/v2/oauth/token",
TokenURL: baseURL + "/api/v2/oauth/token",
Scopes: []string{"device"},
}
ctx := context.Background()
tsClient := tailscale.NewClient("-", nil)
tsClient.HTTPClient = credentials.Client(ctx)
tsClient.BaseURL = baseUrl
tsClient.BaseURL = baseURL
caps := tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{

View File

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

View File

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

View File

@@ -7,10 +7,6 @@ package main
import (
"context"
"crypto/tls"
_ "embed"
"fmt"
"net/http"
"os"
"strings"
"time"
@@ -18,16 +14,11 @@ import (
"github.com/go-logr/zapr"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"golang.org/x/exp/slices"
"golang.org/x/oauth2/clientcredentials"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
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/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/transport"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -38,16 +29,13 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
"sigs.k8s.io/yaml"
"tailscale.com/client/tailscale"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/store/kubestore"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
"tailscale.com/util/dnsname"
"tailscale.com/version"
)
func main() {
@@ -56,14 +44,10 @@ func main() {
tailscale.I_Acknowledge_This_API_Is_Unstable = true
var (
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "")
tslogging = defaultEnv("OPERATOR_LOGGING", "info")
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
shouldRunAuthProxy = defaultBool("AUTH_PROXY", false)
)
@@ -79,8 +63,29 @@ func main() {
}
zlog := kzap.NewRaw(opts...).Sugar()
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
startlog := zlog.Named("startup")
s, tsClient := initTSNet(zlog)
defer s.Close()
restConfig := config.GetConfigOrDie()
if shouldRunAuthProxy {
launchAuthProxy(zlog, restConfig, s)
}
startReconcilers(zlog, tsNamespace, restConfig, tsClient, image, priorityClassName, tags)
}
// 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", "")
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
)
startlog := zlog.Named("startup")
if clientIDPath == "" || clientSecretPath == "" {
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set")
}
@@ -100,12 +105,6 @@ func main() {
tsClient := tailscale.NewClient("-", nil)
tsClient.HTTPClient = credentials.Client(context.Background())
if shouldRunAuthProxy {
hostinfo.SetApp("k8s-operator-proxy")
} else {
hostinfo.SetApp("k8s-operator")
}
s := &tsnet.Server{
Hostname: hostname,
Logf: zlog.Named("tailscaled").Debugf,
@@ -120,7 +119,6 @@ func main() {
if err := s.Start(); err != nil {
startlog.Fatalf("starting tailscale server: %v", err)
}
defer s.Close()
lc, err := s.LocalClient()
if err != nil {
startlog.Fatalf("getting local client: %v", err)
@@ -176,39 +174,35 @@ waitOnline:
}
time.Sleep(time.Second)
}
return s, tsClient
}
// startReconcilers starts the controller-runtime manager and registers the
// ServiceReconciler.
func startReconcilers(zlog *zap.SugaredLogger, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags string) {
startlog := zlog.Named("startReconcilers")
// For secrets and statefulsets, we only get permission to touch the objects
// in the controller's own namespace. This cannot be expressed by
// .Watches(...) below, instead you have to add a per-type field selector to
// the cache that sits a few layers below the builder stuff, which will
// implicitly filter what parts of the world the builder code gets to see at
// all.
nsFilter := cache.ObjectSelector{
Field: fields.SelectorFromSet(fields.Set{"metadata.namespace": tsNamespace}),
nsFilter := cache.ByObject{
Field: client.InNamespace(tsNamespace).AsSelector(),
}
restConfig := config.GetConfigOrDie()
mgr, err := manager.New(restConfig, manager.Options{
NewCache: cache.BuilderWithOptions(cache.Options{
SelectorsByObject: map[client.Object]cache.ObjectSelector{
Cache: cache.Options{
ByObject: map[client.Object]cache.ByObject{
&corev1.Secret{}: nsFilter,
&appsv1.StatefulSet{}: nsFilter,
},
}),
},
})
if err != nil {
startlog.Fatalf("could not create manager: %v", err)
}
sr := &ServiceReconciler{
Client: mgr.GetClient(),
tsClient: tsClient,
defaultTags: strings.Split(tags, ","),
operatorNamespace: tsNamespace,
proxyImage: image,
logger: zlog.Named("service-reconciler"),
}
reconcileFilter := handler.EnqueueRequestsFromMapFunc(func(o client.Object) []reconcile.Request {
reconcileFilter := handler.EnqueueRequestsFromMapFunc(func(_ context.Context, o client.Object) []reconcile.Request {
ls := o.GetLabels()
if ls[LabelManaged] != "true" {
return nil
@@ -228,518 +222,31 @@ waitOnline:
err = builder.
ControllerManagedBy(mgr).
For(&corev1.Service{}).
Watches(&source.Kind{Type: &appsv1.StatefulSet{}}, reconcileFilter).
Watches(&source.Kind{Type: &corev1.Secret{}}, reconcileFilter).
Complete(sr)
Watches(&appsv1.StatefulSet{}, reconcileFilter).
Watches(&corev1.Secret{}, reconcileFilter).
Complete(&ServiceReconciler{
ssr: &tailscaleSTSReconciler{
Client: mgr.GetClient(),
tsClient: tsClient,
defaultTags: strings.Split(tags, ","),
operatorNamespace: tsNamespace,
proxyImage: image,
proxyPriorityClassName: priorityClassName,
},
Client: mgr.GetClient(),
logger: zlog.Named("service-reconciler"),
})
if err != nil {
startlog.Fatalf("could not create controller: %v", err)
}
startlog.Infof("Startup complete, operator running")
if shouldRunAuthProxy {
cfg, err := restConfig.TransportConfig()
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
// Kubernetes uses SPDY for exec and port-forward, however SPDY is
// incompatible with HTTP/2; so disable HTTP/2 in the proxy.
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig, err = transport.TLSConfigFor(cfg)
if err != nil {
startlog.Fatalf("could not get transport.TLSConfigFor(): %v", err)
}
tr.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
rt, err := transport.HTTPWrappersForConfig(cfg, tr)
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
go runAuthProxy(s, rt, zlog.Named("auth-proxy").Infof)
}
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
startlog.Fatalf("could not start manager: %v", err)
}
}
const (
LabelManaged = "tailscale.com/managed"
LabelParentType = "tailscale.com/parent-resource-type"
LabelParentName = "tailscale.com/parent-resource"
LabelParentNamespace = "tailscale.com/parent-resource-ns"
FinalizerName = "tailscale.com/finalizer"
AnnotationExpose = "tailscale.com/expose"
AnnotationTags = "tailscale.com/tags"
AnnotationHostname = "tailscale.com/hostname"
)
// ServiceReconciler is a simple ControllerManagedBy example implementation.
type ServiceReconciler struct {
client.Client
tsClient tsClient
defaultTags []string
operatorNamespace string
proxyImage string
logger *zap.SugaredLogger
}
type tsClient interface {
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
DeleteDevice(ctx context.Context, id string) error
}
func childResourceLabels(parent *corev1.Service) map[string]string {
// You might wonder why we're using owner references, since they seem to be
// built for exactly this. Unfortunately, Kubernetes does not support
// cross-namespace ownership, by design. This means we cannot make the
// service being exposed the owner of the implementation details of the
// proxying. Instead, we have to do our own filtering and tracking with
// labels.
return map[string]string{
LabelManaged: "true",
LabelParentName: parent.GetName(),
LabelParentNamespace: parent.GetNamespace(),
LabelParentType: "svc",
}
}
func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
logger := a.logger.With("service-ns", req.Namespace, "service-name", req.Name)
logger.Debugf("starting reconcile")
defer logger.Debugf("reconcile finished")
svc := new(corev1.Service)
err = a.Get(ctx, req.NamespacedName, svc)
if apierrors.IsNotFound(err) {
// Request object not found, could have been deleted after reconcile request.
logger.Debugf("service not found, assuming it was deleted")
return reconcile.Result{}, nil
} else if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
}
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) {
logger.Debugf("service is being deleted or should not be exposed, cleaning up")
return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc)
}
return reconcile.Result{}, a.maybeProvision(ctx, logger, svc)
}
// maybeCleanup removes any existing resources related to serving svc over tailscale.
//
// This function is responsible for removing the finalizer from the service,
// once all associated resources are gone.
func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
ix := slices.Index(svc.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
return nil
}
ml := childResourceLabels(svc)
// Need to delete the StatefulSet first, and delete it with foreground
// cascading deletion. That way, the pod that's writing to the Secret will
// stop running before we start looking at the Secret's contents, and
// assuming k8s ordering semantics don't mess with us, that should avoid
// tailscale device deletion races where we fail to notice a device that
// should be removed.
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, ml)
if err != nil {
return fmt.Errorf("getting statefulset: %w", err)
}
if sts != nil {
if !sts.GetDeletionTimestamp().IsZero() {
// Deletion in progress, check again later. We'll get another
// notification when the deletion is complete.
logger.Debugf("waiting for statefulset %s/%s deletion", sts.GetNamespace(), sts.GetName())
return nil
}
err := a.DeleteAllOf(ctx, &appsv1.StatefulSet{}, client.InNamespace(a.operatorNamespace), client.MatchingLabels(ml), client.PropagationPolicy(metav1.DeletePropagationForeground))
if err != nil {
return fmt.Errorf("deleting statefulset: %w", err)
}
logger.Debugf("started deletion of statefulset %s/%s", sts.GetNamespace(), sts.GetName())
return nil
}
id, _, err := a.getDeviceInfo(ctx, svc)
if err != nil {
return fmt.Errorf("getting device info: %w", err)
}
if id != "" {
// TODO: handle case where the device is already deleted, but the secret
// is still around.
if err := a.tsClient.DeleteDevice(ctx, id); err != nil {
return fmt.Errorf("deleting device: %w", err)
}
}
types := []client.Object{
&corev1.Service{},
&corev1.Secret{},
}
for _, typ := range types {
if err := a.DeleteAllOf(ctx, typ, client.InNamespace(a.operatorNamespace), client.MatchingLabels(ml)); err != nil {
return err
}
}
svc.Finalizers = append(svc.Finalizers[:ix], svc.Finalizers[ix+1:]...)
if err := a.Update(ctx, svc); err != nil {
return fmt.Errorf("failed to remove finalizer: %w", err)
}
// Unlike most log entries in the reconcile loop, this will get printed
// exactly once at the very end of cleanup, because the final step of
// cleanup removes the tailscale finalizer, which will make all future
// reconciles exit early.
logger.Infof("unexposed service from tailnet")
return nil
}
// maybeProvision ensures that svc is exposed over tailscale, taking any actions
// necessary to reach that state.
//
// 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 {
hostname, err := nameForService(svc)
if err != nil {
return err
}
if !slices.Contains(svc.Finalizers, FinalizerName) {
// This log line is printed exactly once during initial provisioning,
// because once the finalizer is in place this block gets skipped. So,
// this is a nice place to tell the operator that the high level,
// multi-reconcile operation is underway.
logger.Infof("exposing service over tailscale")
svc.Finalizers = append(svc.Finalizers, FinalizerName)
if err := a.Update(ctx, svc); err != nil {
return fmt.Errorf("failed to add finalizer: %w", err)
}
}
// Do full reconcile.
hsvc, err := a.reconcileHeadlessService(ctx, logger, svc)
if err != nil {
return fmt.Errorf("failed to reconcile headless service: %w", err)
}
tags := a.defaultTags
if tstr, ok := svc.Annotations[AnnotationTags]; ok {
tags = strings.Split(tstr, ",")
}
secretName, err := a.createOrGetSecret(ctx, logger, svc, hsvc, tags)
if err != nil {
return fmt.Errorf("failed to create or get API key secret: %w", err)
}
_, err = a.reconcileSTS(ctx, logger, svc, hsvc, secretName, hostname)
if err != nil {
return fmt.Errorf("failed to reconcile statefulset: %w", err)
}
if !a.hasLoadBalancerClass(svc) {
logger.Debugf("service is not a LoadBalancer, so not updating ingress")
return nil
}
_, tsHost, err := a.getDeviceInfo(ctx, svc)
if err != nil {
return fmt.Errorf("failed to get device ID: %w", err)
}
if tsHost == "" {
logger.Debugf("no Tailscale hostname known yet, waiting for proxy pod to finish auth")
// No hostname yet. Wait for the proxy pod to auth.
svc.Status.LoadBalancer.Ingress = nil
if err := a.Status().Update(ctx, svc); err != nil {
return fmt.Errorf("failed to update service status: %w", err)
}
return nil
}
logger.Debugf("setting ingress hostname to %q", tsHost)
svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{
{
Hostname: tsHost,
},
}
if err := a.Status().Update(ctx, svc); err != nil {
return fmt.Errorf("failed to update service status: %w", err)
}
return nil
}
func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool {
// Headless services can't be exposed, since there is no ClusterIP to
// forward to.
if svc.Spec.ClusterIP == "" || svc.Spec.ClusterIP == "None" {
return false
}
return a.hasLoadBalancerClass(svc) || a.hasAnnotation(svc)
}
func (a *ServiceReconciler) hasLoadBalancerClass(svc *corev1.Service) bool {
return svc != nil &&
svc.Spec.Type == corev1.ServiceTypeLoadBalancer &&
svc.Spec.LoadBalancerClass != nil &&
*svc.Spec.LoadBalancerClass == "tailscale"
}
func (a *ServiceReconciler) hasAnnotation(svc *corev1.Service) bool {
return svc != nil &&
svc.Annotations[AnnotationExpose] == "true"
}
func (a *ServiceReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) (*corev1.Service, error) {
hsvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "ts-" + svc.Name + "-",
Namespace: a.operatorNamespace,
Labels: childResourceLabels(svc),
},
Spec: corev1.ServiceSpec{
ClusterIP: "None",
Selector: map[string]string{
"app": string(svc.UID),
},
},
}
logger.Debugf("reconciling headless service for StatefulSet")
return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec })
}
func (a *ServiceReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, svc, hsvc *corev1.Service, tags []string) (string, error) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
// Hardcode a -0 suffix so that in future, if we support
// multiple StatefulSet replicas, we can provision -N for
// those.
Name: hsvc.Name + "-0",
Namespace: a.operatorNamespace,
Labels: childResourceLabels(svc),
},
}
if err := a.Get(ctx, client.ObjectKeyFromObject(secret), secret); err == nil {
logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName())
return secret.Name, nil
} else if !apierrors.IsNotFound(err) {
return "", err
}
// Secret doesn't exist yet, create one. Initially it contains
// only the Tailscale authkey, but once Tailscale starts it'll
// also store the daemon state.
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, childResourceLabels(svc))
if err != nil {
return "", err
}
if sts != nil {
// StatefulSet exists, so we have already created the secret.
// If the secret is missing, they should delete the StatefulSet.
logger.Errorf("Tailscale proxy secret doesn't exist, but the corresponding StatefulSet %s/%s already does. Something is wrong, please delete the StatefulSet.", sts.GetNamespace(), sts.GetName())
return "", nil
}
// Create API Key secret which is going to be used by the statefulset
// to authenticate with Tailscale.
logger.Debugf("creating authkey for new tailscale proxy")
authKey, err := a.newAuthKey(ctx, tags)
if err != nil {
return "", err
}
secret.StringData = map[string]string{
"authkey": authKey,
}
if err := a.Create(ctx, secret); err != nil {
return "", err
}
return secret.Name, nil
}
func (a *ServiceReconciler) getDeviceInfo(ctx context.Context, svc *corev1.Service) (id, hostname string, err error) {
sec, err := getSingleObject[corev1.Secret](ctx, a.Client, a.operatorNamespace, childResourceLabels(svc))
if err != nil {
return "", "", err
}
id = string(sec.Data["device_id"])
if id == "" {
return "", "", nil
}
// Kubernetes chokes on well-formed FQDNs with the trailing dot, so we have
// to remove it.
hostname = strings.TrimSuffix(string(sec.Data["device_fqdn"]), ".")
if hostname == "" {
return "", "", nil
}
return id, hostname, nil
}
func (a *ServiceReconciler) newAuthKey(ctx context.Context, tags []string) (string, error) {
caps := tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
Create: tailscale.KeyDeviceCreateCapabilities{
Reusable: false,
Preauthorized: true,
Tags: tags,
},
},
}
key, _, err := a.tsClient.CreateKey(ctx, caps)
if err != nil {
return "", err
}
return key, nil
}
//go:embed manifests/proxy.yaml
var proxyYaml []byte
func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, parentSvc, headlessSvc *corev1.Service, authKeySecret, hostname string) (*appsv1.StatefulSet, error) {
var ss appsv1.StatefulSet
if err := yaml.Unmarshal(proxyYaml, &ss); err != nil {
return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err)
}
container := &ss.Spec.Template.Spec.Containers[0]
container.Image = a.proxyImage
container.Env = append(container.Env,
corev1.EnvVar{
Name: "TS_DEST_IP",
Value: parentSvc.Spec.ClusterIP,
},
corev1.EnvVar{
Name: "TS_KUBE_SECRET",
Value: authKeySecret,
},
corev1.EnvVar{
Name: "TS_HOSTNAME",
Value: hostname,
})
ss.ObjectMeta = metav1.ObjectMeta{
Name: headlessSvc.Name,
Namespace: a.operatorNamespace,
Labels: childResourceLabels(parentSvc),
}
ss.Spec.ServiceName = headlessSvc.Name
ss.Spec.Selector = &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": string(parentSvc.UID),
},
}
ss.Spec.Template.ObjectMeta.Labels = map[string]string{
"app": string(parentSvc.UID),
}
logger.Debugf("reconciling statefulset %s/%s", ss.GetNamespace(), ss.GetName())
return createOrUpdate(ctx, a.Client, a.operatorNamespace, &ss, func(s *appsv1.StatefulSet) { s.Spec = ss.Spec })
}
// ptrObject is a type constraint for pointer types that implement
// client.Object.
type ptrObject[T any] interface {
client.Object
*T
}
// createOrUpdate adds obj to the k8s cluster, unless the object already exists,
// in which case update is called to make changes to it. If update is nil, the
// existing object is returned unmodified.
//
// obj is looked up by its Name and Namespace if Name is set, otherwise it's
// looked up by labels.
func createOrUpdate[T any, O ptrObject[T]](ctx context.Context, c client.Client, ns string, obj O, update func(O)) (O, error) {
var (
existing O
err error
)
if obj.GetName() != "" {
existing = new(T)
existing.SetName(obj.GetName())
existing.SetNamespace(obj.GetNamespace())
err = c.Get(ctx, client.ObjectKeyFromObject(obj), existing)
} else {
existing, err = getSingleObject[T, O](ctx, c, ns, obj.GetLabels())
}
if err == nil && existing != nil {
if update != nil {
update(existing)
if err := c.Update(ctx, existing); err != nil {
return nil, err
}
}
return existing, nil
}
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("failed to get object: %w", err)
}
if err := c.Create(ctx, obj); err != nil {
return nil, err
}
return obj, nil
}
// getSingleObject searches for k8s objects of type T
// (e.g. corev1.Service) with the given labels, and returns
// it. Returns nil if no objects match the labels, and an error if
// more than one object matches.
func getSingleObject[T any, O ptrObject[T]](ctx context.Context, c client.Client, ns string, labels map[string]string) (O, error) {
ret := O(new(T))
kinds, _, err := c.Scheme().ObjectKinds(ret)
if err != nil {
return nil, err
}
if len(kinds) != 1 {
// TODO: the runtime package apparently has a "pick the best
// GVK" function somewhere that might be good enough?
return nil, fmt.Errorf("more than 1 GroupVersionKind for %T", ret)
}
gvk := kinds[0]
gvk.Kind += "List"
lst := unstructured.UnstructuredList{}
lst.SetGroupVersionKind(gvk)
if err := c.List(ctx, &lst, client.InNamespace(ns), client.MatchingLabels(labels)); err != nil {
return nil, err
}
if len(lst.Items) == 0 {
return nil, nil
}
if len(lst.Items) > 1 {
return nil, fmt.Errorf("found multiple matching %T objects", ret)
}
if err := c.Scheme().Convert(&lst.Items[0], ret, nil); err != nil {
return nil, err
}
return ret, nil
}
func defaultBool(envName string, defVal bool) bool {
vs := os.Getenv(envName)
if vs == "" {
return defVal
}
v, _ := opt.Bool(vs).Get()
return v
}
func defaultEnv(envName, defVal string) string {
v := os.Getenv(envName)
if v == "" {
return defVal
}
return v
}
func nameForService(svc *corev1.Service) (string, error) {
if h, ok := svc.Annotations[AnnotationHostname]; ok {
if err := dnsname.ValidLabel(h); err != nil {
return "", fmt.Errorf("invalid Tailscale hostname %q: %w", h, err)
}
return h, nil
}
return svc.Namespace + "-" + svc.Name, nil
DeleteDevice(ctx context.Context, nodeStableID string) error
}

View File

@@ -14,7 +14,6 @@ import (
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@@ -33,12 +32,15 @@ func TestLoadBalancerClass(t *testing.T) {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
logger: zl.Sugar(),
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
@@ -65,7 +67,7 @@ func TestLoadBalancerClass(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
@@ -111,6 +113,8 @@ func TestLoadBalancerClass(t *testing.T) {
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.Spec.Type = corev1.ServiceTypeClusterIP
s.Spec.LoadBalancerClass = nil
})
mustUpdateStatus(t, fc, "default", "test", func(s *corev1.Service) {
// Fake client doesn't automatically delete the LoadBalancer status when
// changing away from the LoadBalancer type, we have to do
// controller-manager's work by hand.
@@ -152,12 +156,15 @@ func TestAnnotations(t *testing.T) {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
logger: zl.Sugar(),
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
@@ -186,7 +193,7 @@ func TestAnnotations(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -249,12 +256,15 @@ func TestAnnotationIntoLB(t *testing.T) {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
logger: zl.Sugar(),
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
@@ -283,7 +293,7 @@ func TestAnnotationIntoLB(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
// 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
@@ -327,7 +337,7 @@ 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"))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
// ... but the service should have a LoadBalancer status.
want = &corev1.Service{
@@ -367,12 +377,15 @@ func TestLBIntoAnnotation(t *testing.T) {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
logger: zl.Sugar(),
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
@@ -399,7 +412,7 @@ func TestLBIntoAnnotation(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
@@ -448,6 +461,8 @@ func TestLBIntoAnnotation(t *testing.T) {
}
s.Spec.Type = corev1.ServiceTypeClusterIP
s.Spec.LoadBalancerClass = nil
})
mustUpdateStatus(t, fc, "default", "test", func(s *corev1.Service) {
// Fake client doesn't automatically delete the LoadBalancer status when
// changing away from the LoadBalancer type, we have to do
// controller-manager's work by hand.
@@ -456,7 +471,7 @@ func TestLBIntoAnnotation(t *testing.T) {
expectReconciled(t, sr, "default", "test")
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
@@ -488,12 +503,15 @@ func TestCustomHostname(t *testing.T) {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
logger: zl.Sugar(),
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
@@ -523,7 +541,7 @@ func TestCustomHostname(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "reindeer-flotilla"))
expectEqual(t, fc, expectedSTS(shortName, fullName, "reindeer-flotilla", ""))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -582,6 +600,54 @@ func TestCustomHostname(t *testing.T) {
expectEqual(t, fc, want)
}
func TestCustomPriorityClassName(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",
proxyPriorityClassName: "tailscale-critical",
},
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{
"tailscale.com/expose": "true",
"tailscale.com/hostname": "custom-priority-class-name",
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
expectEqual(t, fc, expectedSTS(shortName, fullName, "custom-priority-class-name", "tailscale-critical"))
}
func expectedSecret(name string) *corev1.Secret {
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
@@ -630,7 +696,7 @@ func expectedHeadlessService(name string) *corev1.Service {
}
}
func expectedSTS(stsName, secretName, hostname string) *appsv1.StatefulSet {
func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
@@ -659,6 +725,7 @@ func expectedSTS(stsName, secretName, hostname string) *appsv1.StatefulSet {
},
Spec: corev1.PodSpec{
ServiceAccountName: "proxies",
PriorityClassName: priorityClassName,
InitContainers: []corev1.Container{
{
Name: "sysctler",
@@ -670,11 +737,11 @@ func expectedSTS(stsName, secretName, hostname string) *appsv1.StatefulSet {
},
},
},
Containers: []v1.Container{
Containers: []corev1.Container{
{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: []v1.EnvVar{
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_DEST_IP", Value: "10.20.30.40"},
@@ -732,6 +799,21 @@ func mustUpdate[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, n
}
}
func mustUpdateStatus[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) {
t.Helper()
obj := O(new(T))
if err := client.Get(context.Background(), types.NamespacedName{
Name: name,
Namespace: ns,
}, obj); err != nil {
t.Fatalf("getting %q: %v", name, err)
}
update(obj)
if err := client.Status().Update(context.Background(), obj); err != nil {
t.Fatalf("updating %q: %v", name, err)
}
}
func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want O) {
t.Helper()
got := O(new(T))
@@ -815,7 +897,6 @@ func (c *fakeTSClient) CreateKey(ctx context.Context, caps tailscale.KeyCapabili
k := &tailscale.Key{
ID: "key",
Created: time.Now(),
Expires: time.Now().Add(24 * time.Hour),
Capabilities: caps,
}
return "secret-authkey", k, nil

View File

@@ -14,14 +14,59 @@ import (
"os"
"strings"
"go.uber.org/zap"
"k8s.io/client-go/rest"
"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/set"
)
type whoIsKey struct{}
// whoIsFromRequest returns the WhoIsResponse previously stashed by a call to
// addWhoIsToRequest.
func whoIsFromRequest(r *http.Request) *apitype.WhoIsResponse {
return r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse)
}
// addWhoIsToRequest stashes who in r's context, retrievable by a call to
// whoIsFromRequest.
func addWhoIsToRequest(r *http.Request, who *apitype.WhoIsResponse) *http.Request {
return r.WithContext(context.WithValue(r.Context(), whoIsKey{}, who))
}
// launchAuthProxy launches the auth proxy, which is a small HTTP server that
// authenticates requests using the Tailscale LocalAPI and then proxies them to
// the kube-apiserver.
func launchAuthProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server) {
hostinfo.SetApp("k8s-operator-proxy")
startlog := zlog.Named("launchAuthProxy")
cfg, err := restConfig.TransportConfig()
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
// Kubernetes uses SPDY for exec and port-forward, however SPDY is
// incompatible with HTTP/2; so disable HTTP/2 in the proxy.
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig, err = transport.TLSConfigFor(cfg)
if err != nil {
startlog.Fatalf("could not get transport.TLSConfigFor(): %v", err)
}
tr.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
rt, err := transport.HTTPWrappersForConfig(cfg, tr)
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
go runAuthProxy(s, rt, zlog.Named("auth-proxy").Infof)
}
// authProxy is an http.Handler that authenticates requests using the Tailscale
// LocalAPI and then proxies them to the Kubernetes API.
type authProxy struct {
@@ -37,8 +82,7 @@ func (h *authProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
return
}
r = r.WithContext(context.WithValue(r.Context(), whoIsKey{}, who))
h.rp.ServeHTTP(w, r)
h.rp.ServeHTTP(w, addWhoIsToRequest(r, who))
}
// runAuthProxy runs an HTTP server that authenticates requests using the
@@ -67,6 +111,10 @@ func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) {
lc: lc,
rp: &httputil.ReverseProxy{
Director: func(r *http.Request) {
// Replace the URL with the Kubernetes APIServer.
r.URL.Scheme = u.Scheme
r.URL.Host = u.Host
// We want to proxy to the Kubernetes API, but we want to use
// the caller's identity to do so. We do this by impersonating
// the caller using the Kubernetes User Impersonation feature:
@@ -85,21 +133,9 @@ func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) {
}
// Now add the impersonation headers that we want.
who := r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse)
if who.Node.IsTagged() {
// Use the nodes FQDN as the username, and the nodes tags as the groups.
// "Impersonate-Group" requires "Impersonate-User" to be set.
r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, "."))
for _, tag := range who.Node.Tags {
r.Header.Add("Impersonate-Group", tag)
}
} else {
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
if err := addImpersonationHeaders(r); err != nil {
panic("failed to add impersonation headers: " + err.Error())
}
// Replace the URL with the Kubernetes APIServer.
r.URL.Scheme = u.Scheme
r.URL.Host = u.Host
},
Transport: rt,
},
@@ -118,3 +154,58 @@ func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) {
log.Fatalf("runAuthProxy: failed to serve %v", err)
}
}
const capabilityName = "https://tailscale.com/cap/kubernetes"
type capRule struct {
// Impersonate is a list of rules that specify how to impersonate the caller
// when proxying to the Kubernetes API.
Impersonate *impersonateRule `json:"impersonate,omitempty"`
}
// TODO(maisem): move this to some well-known location so that it can be shared
// with control.
type impersonateRule struct {
Groups []string `json:"groups,omitempty"`
}
// 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 authProxy.
func addImpersonationHeaders(r *http.Request) error {
who := whoIsFromRequest(r)
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, capabilityName)
if err != nil {
return fmt.Errorf("failed to unmarshal capability: %v", err)
}
var groupsAdded set.Slice[string]
for _, rule := range rules {
if rule.Impersonate == nil {
continue
}
for _, group := range rule.Impersonate.Groups {
if groupsAdded.Contains(group) {
continue
}
r.Header.Add("Impersonate-Group", group)
groupsAdded.Add(group)
}
}
if !who.Node.IsTagged() {
r.Header.Set("Impersonate-User", 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, "."))
// 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)
}
}
return nil
}

View File

@@ -0,0 +1,107 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"net/http"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
"tailscale.com/util/must"
)
func TestImpersonationHeaders(t *testing.T) {
tests := []struct {
name string
emailish string
tags []string
capMap tailcfg.PeerCapMap
wantHeaders http.Header
}{
{
name: "user",
emailish: "foo@example.com",
wantHeaders: http.Header{
"Impersonate-User": {"foo@example.com"},
},
},
{
name: "tagged",
emailish: "tagged-device",
tags: []string{"tag:foo", "tag:bar"},
wantHeaders: http.Header{
"Impersonate-User": {"node.ts.net"},
"Impersonate-Group": {"tag:foo", "tag:bar"},
},
},
{
name: "user-with-cap",
emailish: "foo@example.com",
capMap: tailcfg.PeerCapMap{
capabilityName: {
[]byte(`{"impersonate":{"groups":["group1","group2"]}}`),
[]byte(`{"impersonate":{"groups":["group1","group3"]}}`), // One group is duplicated.
[]byte(`{"impersonate":{"groups":["group4"]}}`),
[]byte(`{"impersonate":{"groups":["group2"]}}`), // duplicate
// These should be ignored, but should parse correctly.
[]byte(`{}`),
[]byte(`{"impersonate":{}}`),
[]byte(`{"impersonate":{"groups":[]}}`),
},
},
wantHeaders: http.Header{
"Impersonate-Group": {"group1", "group2", "group3", "group4"},
"Impersonate-User": {"foo@example.com"},
},
},
{
name: "tagged-with-cap",
emailish: "tagged-device",
tags: []string{"tag:foo", "tag:bar"},
capMap: tailcfg.PeerCapMap{
capabilityName: {
[]byte(`{"impersonate":{"groups":["group1"]}}`),
},
},
wantHeaders: http.Header{
"Impersonate-Group": {"group1"},
"Impersonate-User": {"node.ts.net"},
},
},
{
name: "bad-cap",
emailish: "tagged-device",
tags: []string{"tag:foo", "tag:bar"},
capMap: tailcfg.PeerCapMap{
capabilityName: {
[]byte(`[]`),
},
},
wantHeaders: http.Header{},
},
}
for _, tc := range tests {
r := must.Get(http.NewRequest("GET", "https://op.ts.net/api/foo", nil))
r = addWhoIsToRequest(r, &apitype.WhoIsResponse{
Node: &tailcfg.Node{
Name: "node.ts.net",
Tags: tc.tags,
},
UserProfile: &tailcfg.UserProfile{
LoginName: tc.emailish,
},
CapMap: tc.capMap,
})
addImpersonationHeaders(r)
if d := cmp.Diff(tc.wantHeaders, r.Header); d != "" {
t.Errorf("unexpected header (-want +got):\n%s", d)
}
}
}

392
cmd/k8s-operator/sts.go Normal file
View File

@@ -0,0 +1,392 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
_ "embed"
"fmt"
"os"
"strings"
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
"tailscale.com/client/tailscale"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"tailscale.com/util/dnsname"
)
const (
LabelManaged = "tailscale.com/managed"
LabelParentType = "tailscale.com/parent-resource-type"
LabelParentName = "tailscale.com/parent-resource"
LabelParentNamespace = "tailscale.com/parent-resource-ns"
FinalizerName = "tailscale.com/finalizer"
AnnotationExpose = "tailscale.com/expose"
AnnotationTags = "tailscale.com/tags"
AnnotationHostname = "tailscale.com/hostname"
)
type tailscaleSTSConfig struct {
ParentResourceName string
ParentResourceUID string
ChildResourceLabels map[string]string
TargetIP string
Hostname string
Tags []string // if empty, use defaultTags
}
type tailscaleSTSReconciler struct {
client.Client
tsClient tsClient
defaultTags []string
operatorNamespace string
proxyImage string
proxyPriorityClassName string
}
// Provision ensures that the StatefulSet for the given service is running and
// up to date.
func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) error {
// Do full reconcile.
hsvc, err := a.reconcileHeadlessService(ctx, logger, sts)
if err != nil {
return fmt.Errorf("failed to reconcile headless service: %w", err)
}
secretName, err := a.createOrGetSecret(ctx, logger, sts, hsvc)
if err != nil {
return fmt.Errorf("failed to create or get API key secret: %w", err)
}
_, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName)
if err != nil {
return fmt.Errorf("failed to reconcile statefulset: %w", err)
}
return nil
}
// Cleanup removes all resources associated that were created by Provision with
// the given labels. It returns true when all resources have been removed,
// otherwise it returns false and the caller should retry later.
func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.SugaredLogger, labels map[string]string) (done bool, _ error) {
// Need to delete the StatefulSet first, and delete it with foreground
// cascading deletion. That way, the pod that's writing to the Secret will
// stop running before we start looking at the Secret's contents, and
// assuming k8s ordering semantics don't mess with us, that should avoid
// tailscale device deletion races where we fail to notice a device that
// should be removed.
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, labels)
if err != nil {
return false, fmt.Errorf("getting statefulset: %w", err)
}
if sts != nil {
if !sts.GetDeletionTimestamp().IsZero() {
// Deletion in progress, check again later. We'll get another
// notification when the deletion is complete.
logger.Debugf("waiting for statefulset %s/%s deletion", sts.GetNamespace(), sts.GetName())
return false, nil
}
err := a.DeleteAllOf(ctx, &appsv1.StatefulSet{}, client.InNamespace(a.operatorNamespace), client.MatchingLabels(labels), client.PropagationPolicy(metav1.DeletePropagationForeground))
if err != nil {
return false, fmt.Errorf("deleting statefulset: %w", err)
}
logger.Debugf("started deletion of statefulset %s/%s", sts.GetNamespace(), sts.GetName())
return false, nil
}
id, _, err := a.DeviceInfo(ctx, labels)
if err != nil {
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.
if err := a.tsClient.DeleteDevice(ctx, string(id)); err != nil {
return false, fmt.Errorf("deleting device: %w", err)
}
}
types := []client.Object{
&corev1.Service{},
&corev1.Secret{},
}
for _, typ := range types {
if err := a.DeleteAllOf(ctx, typ, client.InNamespace(a.operatorNamespace), client.MatchingLabels(labels)); err != nil {
return false, err
}
}
return true, nil
}
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
hsvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "ts-" + sts.ParentResourceName + "-",
Namespace: a.operatorNamespace,
Labels: sts.ChildResourceLabels,
},
Spec: corev1.ServiceSpec{
ClusterIP: "None",
Selector: map[string]string{
"app": sts.ParentResourceUID,
},
},
}
logger.Debugf("reconciling headless service for StatefulSet")
return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec })
}
func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, stsC *tailscaleSTSConfig, hsvc *corev1.Service) (string, error) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
// Hardcode a -0 suffix so that in future, if we support
// multiple StatefulSet replicas, we can provision -N for
// those.
Name: hsvc.Name + "-0",
Namespace: a.operatorNamespace,
Labels: stsC.ChildResourceLabels,
},
}
if err := a.Get(ctx, client.ObjectKeyFromObject(secret), secret); err == nil {
logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName())
return secret.Name, nil
} else if !apierrors.IsNotFound(err) {
return "", err
}
// Secret doesn't exist yet, create one. Initially it contains
// only the Tailscale authkey, but once Tailscale starts it'll
// also store the daemon state.
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, stsC.ChildResourceLabels)
if err != nil {
return "", err
}
if sts != nil {
// StatefulSet exists, so we have already created the secret.
// If the secret is missing, they should delete the StatefulSet.
logger.Errorf("Tailscale proxy secret doesn't exist, but the corresponding StatefulSet %s/%s already does. Something is wrong, please delete the StatefulSet.", sts.GetNamespace(), sts.GetName())
return "", nil
}
// Create API Key secret which is going to be used by the statefulset
// to authenticate with Tailscale.
logger.Debugf("creating authkey for new tailscale proxy")
tags := stsC.Tags
if len(tags) == 0 {
tags = a.defaultTags
}
authKey, err := a.newAuthKey(ctx, tags)
if err != nil {
return "", err
}
secret.StringData = map[string]string{
"authkey": authKey,
}
if err := a.Create(ctx, secret); err != nil {
return "", err
}
return secret.Name, nil
}
// DeviceInfo returns the device ID and hostname for the Tailscale device
// associated with the given labels.
func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map[string]string) (id tailcfg.StableNodeID, hostname string, err error) {
sec, err := getSingleObject[corev1.Secret](ctx, a.Client, a.operatorNamespace, childLabels)
if err != nil {
return "", "", err
}
if sec == nil {
return "", "", nil
}
id = tailcfg.StableNodeID(sec.Data["device_id"])
if id == "" {
return "", "", nil
}
// Kubernetes chokes on well-formed FQDNs with the trailing dot, so we have
// to remove it.
hostname = strings.TrimSuffix(string(sec.Data["device_fqdn"]), ".")
if hostname == "" {
return "", "", nil
}
return id, hostname, nil
}
func (a *tailscaleSTSReconciler) newAuthKey(ctx context.Context, tags []string) (string, error) {
caps := tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
Create: tailscale.KeyDeviceCreateCapabilities{
Reusable: false,
Preauthorized: true,
Tags: tags,
},
},
}
key, _, err := a.tsClient.CreateKey(ctx, caps)
if err != nil {
return "", err
}
return key, nil
}
//go:embed manifests/proxy.yaml
var proxyYaml []byte
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, authKeySecret string) (*appsv1.StatefulSet, error) {
var ss appsv1.StatefulSet
if err := yaml.Unmarshal(proxyYaml, &ss); err != nil {
return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err)
}
container := &ss.Spec.Template.Spec.Containers[0]
container.Image = a.proxyImage
container.Env = append(container.Env,
corev1.EnvVar{
Name: "TS_DEST_IP",
Value: sts.TargetIP,
},
corev1.EnvVar{
Name: "TS_KUBE_SECRET",
Value: authKeySecret,
},
corev1.EnvVar{
Name: "TS_HOSTNAME",
Value: sts.Hostname,
})
ss.ObjectMeta = metav1.ObjectMeta{
Name: headlessSvc.Name,
Namespace: a.operatorNamespace,
Labels: sts.ChildResourceLabels,
}
ss.Spec.ServiceName = headlessSvc.Name
ss.Spec.Selector = &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": sts.ParentResourceUID,
},
}
ss.Spec.Template.ObjectMeta.Labels = map[string]string{
"app": sts.ParentResourceUID,
}
ss.Spec.Template.Spec.PriorityClassName = a.proxyPriorityClassName
logger.Debugf("reconciling statefulset %s/%s", ss.GetNamespace(), ss.GetName())
return createOrUpdate(ctx, a.Client, a.operatorNamespace, &ss, func(s *appsv1.StatefulSet) { s.Spec = ss.Spec })
}
// ptrObject is a type constraint for pointer types that implement
// client.Object.
type ptrObject[T any] interface {
client.Object
*T
}
// createOrUpdate adds obj to the k8s cluster, unless the object already exists,
// in which case update is called to make changes to it. If update is nil, the
// existing object is returned unmodified.
//
// obj is looked up by its Name and Namespace if Name is set, otherwise it's
// looked up by labels.
func createOrUpdate[T any, O ptrObject[T]](ctx context.Context, c client.Client, ns string, obj O, update func(O)) (O, error) {
var (
existing O
err error
)
if obj.GetName() != "" {
existing = new(T)
existing.SetName(obj.GetName())
existing.SetNamespace(obj.GetNamespace())
err = c.Get(ctx, client.ObjectKeyFromObject(obj), existing)
} else {
existing, err = getSingleObject[T, O](ctx, c, ns, obj.GetLabels())
}
if err == nil && existing != nil {
if update != nil {
update(existing)
if err := c.Update(ctx, existing); err != nil {
return nil, err
}
}
return existing, nil
}
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("failed to get object: %w", err)
}
if err := c.Create(ctx, obj); err != nil {
return nil, err
}
return obj, nil
}
// getSingleObject searches for k8s objects of type T
// (e.g. corev1.Service) with the given labels, and returns
// it. Returns nil if no objects match the labels, and an error if
// more than one object matches.
func getSingleObject[T any, O ptrObject[T]](ctx context.Context, c client.Client, ns string, labels map[string]string) (O, error) {
ret := O(new(T))
kinds, _, err := c.Scheme().ObjectKinds(ret)
if err != nil {
return nil, err
}
if len(kinds) != 1 {
// TODO: the runtime package apparently has a "pick the best
// GVK" function somewhere that might be good enough?
return nil, fmt.Errorf("more than 1 GroupVersionKind for %T", ret)
}
gvk := kinds[0]
gvk.Kind += "List"
lst := unstructured.UnstructuredList{}
lst.SetGroupVersionKind(gvk)
if err := c.List(ctx, &lst, client.InNamespace(ns), client.MatchingLabels(labels)); err != nil {
return nil, err
}
if len(lst.Items) == 0 {
return nil, nil
}
if len(lst.Items) > 1 {
return nil, fmt.Errorf("found multiple matching %T objects", ret)
}
if err := c.Scheme().Convert(&lst.Items[0], ret, nil); err != nil {
return nil, err
}
return ret, nil
}
func defaultBool(envName string, defVal bool) bool {
vs := os.Getenv(envName)
if vs == "" {
return defVal
}
v, _ := opt.Bool(vs).Get()
return v
}
func defaultEnv(envName, defVal string) string {
v := os.Getenv(envName)
if v == "" {
return defVal
}
return v
}
func nameForService(svc *corev1.Service) (string, error) {
if h, ok := svc.Annotations[AnnotationHostname]; ok {
if err := dnsname.ValidLabel(h); err != nil {
return "", fmt.Errorf("invalid Tailscale hostname %q: %w", h, err)
}
return h, nil
}
return svc.Namespace + "-" + svc.Name, nil
}

185
cmd/k8s-operator/svc.go Normal file
View File

@@ -0,0 +1,185 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"fmt"
"strings"
"go.uber.org/zap"
"golang.org/x/exp/slices"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
type ServiceReconciler struct {
client.Client
ssr *tailscaleSTSReconciler
logger *zap.SugaredLogger
}
func childResourceLabels(parent *corev1.Service) map[string]string {
// You might wonder why we're using owner references, since they seem to be
// built for exactly this. Unfortunately, Kubernetes does not support
// cross-namespace ownership, by design. This means we cannot make the
// service being exposed the owner of the implementation details of the
// proxying. Instead, we have to do our own filtering and tracking with
// labels.
return map[string]string{
LabelManaged: "true",
LabelParentName: parent.GetName(),
LabelParentNamespace: parent.GetNamespace(),
LabelParentType: "svc",
}
}
func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
logger := a.logger.With("service-ns", req.Namespace, "service-name", req.Name)
logger.Debugf("starting reconcile")
defer logger.Debugf("reconcile finished")
svc := new(corev1.Service)
err = a.Get(ctx, req.NamespacedName, svc)
if apierrors.IsNotFound(err) {
// Request object not found, could have been deleted after reconcile request.
logger.Debugf("service not found, assuming it was deleted")
return reconcile.Result{}, nil
} else if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
}
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) {
logger.Debugf("service is being deleted or should not be exposed, cleaning up")
return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc)
}
return reconcile.Result{}, a.maybeProvision(ctx, logger, svc)
}
// maybeCleanup removes any existing resources related to serving svc over tailscale.
//
// This function is responsible for removing the finalizer from the service,
// once all associated resources are gone.
func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
ix := slices.Index(svc.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
return nil
}
if done, err := a.ssr.Cleanup(ctx, logger, childResourceLabels(svc)); err != nil {
return fmt.Errorf("failed to cleanup: %w", err)
} else if !done {
logger.Debugf("cleanup not done yet, waiting for next reconcile")
return nil
}
svc.Finalizers = append(svc.Finalizers[:ix], svc.Finalizers[ix+1:]...)
if err := a.Update(ctx, svc); err != nil {
return fmt.Errorf("failed to remove finalizer: %w", err)
}
// Unlike most log entries in the reconcile loop, this will get printed
// exactly once at the very end of cleanup, because the final step of
// cleanup removes the tailscale finalizer, which will make all future
// reconciles exit early.
logger.Infof("unexposed service from tailnet")
return nil
}
// maybeProvision ensures that svc is exposed over tailscale, taking any actions
// necessary to reach that state.
//
// 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 {
hostname, err := nameForService(svc)
if err != nil {
return err
}
if !slices.Contains(svc.Finalizers, FinalizerName) {
// This log line is printed exactly once during initial provisioning,
// because once the finalizer is in place this block gets skipped. So,
// this is a nice place to tell the operator that the high level,
// multi-reconcile operation is underway.
logger.Infof("exposing service over tailscale")
svc.Finalizers = append(svc.Finalizers, FinalizerName)
if err := a.Update(ctx, svc); err != nil {
return fmt.Errorf("failed to add finalizer: %w", err)
}
}
crl := childResourceLabels(svc)
var tags []string
if tstr, ok := svc.Annotations[AnnotationTags]; ok {
tags = strings.Split(tstr, ",")
}
sts := &tailscaleSTSConfig{
ParentResourceName: svc.Name,
ParentResourceUID: string(svc.UID),
TargetIP: svc.Spec.ClusterIP,
Hostname: hostname,
Tags: tags,
ChildResourceLabels: crl,
}
if err := a.ssr.Provision(ctx, logger, sts); err != nil {
return fmt.Errorf("failed to provision: %w", err)
}
if !a.hasLoadBalancerClass(svc) {
logger.Debugf("service is not a LoadBalancer, so not updating ingress")
return nil
}
_, tsHost, err := a.ssr.DeviceInfo(ctx, crl)
if err != nil {
return fmt.Errorf("failed to get device ID: %w", err)
}
if tsHost == "" {
logger.Debugf("no Tailscale hostname known yet, waiting for proxy pod to finish auth")
// No hostname yet. Wait for the proxy pod to auth.
svc.Status.LoadBalancer.Ingress = nil
if err := a.Status().Update(ctx, svc); err != nil {
return fmt.Errorf("failed to update service status: %w", err)
}
return nil
}
logger.Debugf("setting ingress hostname to %q", tsHost)
svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{
{
Hostname: tsHost,
},
}
if err := a.Status().Update(ctx, svc); err != nil {
return fmt.Errorf("failed to update service status: %w", err)
}
return nil
}
func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool {
// Headless services can't be exposed, since there is no ClusterIP to
// forward to.
if svc.Spec.ClusterIP == "" || svc.Spec.ClusterIP == "None" {
return false
}
return a.hasLoadBalancerClass(svc) || a.hasAnnotation(svc)
}
func (a *ServiceReconciler) hasLoadBalancerClass(svc *corev1.Service) bool {
return svc != nil &&
svc.Spec.Type == corev1.ServiceTypeLoadBalancer &&
svc.Spec.LoadBalancerClass != nil &&
*svc.Spec.LoadBalancerClass == "tailscale"
}
func (a *ServiceReconciler) hasAnnotation(svc *corev1.Service) bool {
return svc != nil &&
svc.Annotations[AnnotationExpose] == "true"
}

View File

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

View File

@@ -35,16 +35,16 @@ import (
"net/http"
"net/netip"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/dsnet/try"
jsonv2 "github.com/go-json-experiment/json"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"tailscale.com/types/logid"
"tailscale.com/types/netlogtype"
"tailscale.com/util/cmpx"
"tailscale.com/util/must"
)
@@ -151,10 +151,10 @@ func printMessage(msg message) {
if len(traffic) == 0 {
return
}
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) bool {
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) int {
nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes
ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes
return nx > ny
return cmpx.Compare(ny, nx)
})
var sum netlogtype.Counts
for _, cc := range traffic {
@@ -314,8 +314,8 @@ func mustMakeNamesByAddr() map[netip.Addr]string {
namesByAddr := make(map[netip.Addr]string)
retry:
for i := 0; i < 10; i++ {
maps.Clear(seen)
maps.Clear(namesByAddr)
clear(seen)
clear(namesByAddr)
for _, d := range m.Devices {
name := fieldPrefix(d.Name, i)
if seen[name] {

View File

@@ -272,7 +272,7 @@ func (p *proxy) serve(sessionID int64, c net.Conn) error {
}
if buf[0] != 'S' {
p.errors.Add("upstream-bad-protocol", 1)
return fmt.Errorf("upstream didn't acknowldge start-ssl, said %q", buf[0])
return fmt.Errorf("upstream didn't acknowledge start-ssl, said %q", buf[0])
}
tlsConf := &tls.Config{
ServerName: p.upstreamHost,

View File

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

View File

@@ -19,7 +19,6 @@ import (
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
@@ -149,7 +148,7 @@ func getHostKeys(dir string) (ret []ssh.Signer, err error) {
func hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) {
path := filepath.Join(keyDir, "ssh_host_"+typ+"_key")
v, err := ioutil.ReadFile(path)
v, err := os.ReadFile(path)
if err == nil {
return v, nil
}

View File

@@ -1,57 +0,0 @@
<html>
<head>
<title>Redirecting...</title>
<style>
html,
body {
height: 100%;
}
html {
background-color: rgb(249, 247, 246);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.spinner {
margin-bottom: 2rem;
border: 4px rgba(112, 110, 109, 0.5) solid;
border-left-color: transparent;
border-radius: 9999px;
width: 4rem;
height: 4rem;
-webkit-animation: spin 700ms linear infinite;
animation: spin 800ms linear infinite;
}
.label {
color: rgb(112, 110, 109);
padding-left: 0.4rem;
}
@-webkit-keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head> <body>
<div class="spinner"></div>
<div class="label">Redirecting...</div>
</body>

View File

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

View File

@@ -14,12 +14,12 @@ import (
"log"
"os"
"runtime"
"slices"
"strings"
"sync"
"text/tabwriter"
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/exp/slices"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/paths"
@@ -120,7 +120,7 @@ change in the future.
pingCmd,
ncCmd,
sshCmd,
funnelCmd,
funnelCmd(),
serveCmd,
versionCmd,
webCmd,
@@ -129,16 +129,12 @@ change in the future.
certCmd,
netlockCmd,
licensesCmd,
exitNodeCmd,
},
FlagSet: rootfs,
Exec: func(context.Context, []string) error { return flag.ErrHelp },
UsageFunc: usageFunc,
}
for _, c := range rootCmd.Subcommands {
if c.UsageFunc == nil {
c.UsageFunc = usageFunc
}
}
if envknob.UseWIPCode() {
rootCmd.Subcommands = append(rootCmd.Subcommands,
idTokenCmd,
@@ -156,6 +152,12 @@ change in the future.
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
}
for _, c := range rootCmd.Subcommands {
if c.UsageFunc == nil {
c.UsageFunc = usageFunc
}
}
if err := rootCmd.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil

View File

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

View File

@@ -11,10 +11,10 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/exp/slices"
"k8s.io/client-go/util/homedir"
"sigs.k8s.io/yaml"
"tailscale.com/version"

View File

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

View File

@@ -66,7 +66,7 @@ func isSystemdSystem() bool {
return false
}
switch distro.Get() {
case distro.QNAP, distro.Gokrazy, distro.Synology:
case distro.QNAP, distro.Gokrazy, distro.Synology, distro.Unraid:
return false
}
_, err := exec.LookPath("systemctl")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import (
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/net/netcheck"
"tailscale.com/net/netmon"
"tailscale.com/net/portmapper"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
@@ -45,9 +46,13 @@ var netcheckArgs struct {
}
func runNetcheck(ctx context.Context, args []string) error {
logf := logger.WithPrefix(log.Printf, "portmap: ")
netMon, err := netmon.New(logf)
if err != nil {
return err
}
c := &netcheck.Client{
UDPBindAddr: envknob.String("TS_DEBUG_NETCHECK_UDP_BIND"),
PortMapper: portmapper.NewClient(logger.WithPrefix(log.Printf, "portmap: "), nil, nil),
PortMapper: portmapper.NewClient(logf, netMon, nil, nil),
UseDNSCache: false, // always resolve, don't cache
}
if netcheckArgs.verbose {
@@ -61,6 +66,10 @@ func runNetcheck(ctx context.Context, args []string) error {
fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface")
}
if err := c.Standalone(ctx, envknob.String("TS_DEBUG_NETCHECK_UDP_BIND")); err != nil {
fmt.Fprintln(Stderr, "netcheck: UDP test failure:", err)
}
dm, err := localClient.CurrentDERPMap(ctx)
noRegions := dm != nil && len(dm.Regions) == 0
if noRegions {
@@ -97,7 +106,6 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
var err error
switch netcheckArgs.format {
case "":
break
case "json":
j, err = json.MarshalIndent(report, "", "\t")
case "json-line":

View File

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

View File

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

View File

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

View File

@@ -10,19 +10,24 @@ import (
"flag"
"fmt"
"io"
"log"
"net"
"net/url"
"os"
"path"
"path/filepath"
"reflect"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
"tailscale.com/version"
)
@@ -34,12 +39,14 @@ func newServeCommand(e *serveEnv) *ffcli.Command {
return &ffcli.Command{
Name: "serve",
ShortHelp: "Serve content and local servers",
ShortUsage: strings.TrimSpace(`
serve https:<port> <mount-point> <source> [off]
serve tcp:<port> tcp://localhost:<local-port> [off]
serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off]
serve status [--json]
`),
ShortUsage: strings.Join([]string{
"serve http:<port> <mount-point> <source> [off]",
"serve https:<port> <mount-point> <source> [off]",
"serve tcp:<port> tcp://localhost:<local-port> [off]",
"serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off]",
"serve status [--json]",
"serve reset",
}, "\n "),
LongHelp: strings.TrimSpace(`
*** BETA; all of this is subject to change ***
@@ -56,8 +63,8 @@ EXAMPLES
- To proxy requests to a web server at 127.0.0.1:3000:
$ tailscale serve https:443 / http://127.0.0.1:3000
Or, using the default port:
$ tailscale serve https / http://127.0.0.1:3000
Or, using the default port (443):
$ tailscale serve https / http://127.0.0.1:3000
- To serve a single file or a directory of files:
$ tailscale serve https / /home/alice/blog/index.html
@@ -66,6 +73,12 @@ EXAMPLES
- To serve simple static text:
$ tailscale serve https:8080 / text:"Hello, world!"
- To serve over HTTP (tailnet only):
$ tailscale serve http:80 / http://127.0.0.1:3000
Or, using the default port (80):
$ tailscale serve http / http://127.0.0.1:3000
- To forward incoming TCP connections on port 2222 to a local TCP server on
port 22 (e.g. to run OpenSSH in parallel with Tailscale SSH):
$ tailscale serve tcp:2222 tcp://localhost:22
@@ -86,6 +99,13 @@ EXAMPLES
}),
UsageFunc: usageFunc,
},
{
Name: "reset",
Exec: e.runServeReset,
ShortHelp: "reset current serve/funnel config",
FlagSet: e.newFlags("serve-reset", nil),
UsageFunc: usageFunc,
},
},
}
}
@@ -109,9 +129,14 @@ func (e *serveEnv) newFlags(name string, setup func(fs *flag.FlagSet)) *flag.Fla
//
// The purpose of this interface is to allow tests to provide a mock.
type localServeClient interface {
Status(context.Context) (*ipnstate.Status, error)
StatusWithoutPeers(context.Context) (*ipnstate.Status, error)
GetServeConfig(context.Context) (*ipn.ServeConfig, error)
GetMemoryServeConfig(context.Context) (*ipn.ServeConfig, error)
SetServeConfig(context.Context, *ipn.ServeConfig) error
QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error)
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error)
IncrementCounter(ctx context.Context, name string, delta int) error
StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) // TODO: testing :)
}
// serveEnv is the environment the serve command runs within. All I/O should be
@@ -122,7 +147,8 @@ type localServeClient interface {
// It also contains the flags, as registered with newServeCommand.
type serveEnv struct {
// flags
json bool // output JSON (status only for now)
json bool // output JSON (status only for now)
memory bool // output memory (status only for now)
lc localServeClient // localClient interface, specific to serve
@@ -135,19 +161,21 @@ type serveEnv struct {
// The trailing dot is removed.
// Returns an error if local client status fails.
func (e *serveEnv) getSelfDNSName(ctx context.Context) (string, error) {
st, err := e.getLocalClientStatus(ctx)
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return "", fmt.Errorf("getting client status: %w", err)
}
return strings.TrimSuffix(st.Self.DNSName, "."), nil
}
// getLocalClientStatus returns the Status of the local client.
// getLocalClientStatusWithoutPeers returns the Status of the local client
// without any peers in the response.
//
// Returns error if unable to reach tailscaled or if self node is nil.
//
// Exits if status is not running or starting.
func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, error) {
st, err := e.lc.Status(ctx)
func (e *serveEnv) getLocalClientStatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
st, err := e.lc.StatusWithoutPeers(ctx)
if err != nil {
return nil, fixTailscaledConnectError(err)
}
@@ -166,6 +194,7 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status,
// serve config types like proxy, path, and text.
//
// Examples:
// - tailscale serve http / http://localhost:3000
// - tailscale serve https / http://localhost:3000
// - tailscale serve https /images/ /var/www/images/
// - tailscale serve https:10000 /motd.txt text:"Hello, world!"
@@ -190,19 +219,14 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
return e.lc.SetServeConfig(ctx, sc)
}
parsePort := func(portStr string) (uint16, error) {
port64, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return 0, err
}
return uint16(port64), nil
}
srcType, srcPortStr, found := strings.Cut(args[0], ":")
if !found {
if srcType == "https" && srcPortStr == "" {
// Default https port to 443.
srcPortStr = "443"
} else if srcType == "http" && srcPortStr == "" {
// Default http port to 80.
srcPortStr = "80"
} else {
return flag.ErrHelp
}
@@ -210,18 +234,33 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
turnOff := "off" == args[len(args)-1]
if len(args) < 2 || (srcType == "https" && !turnOff && len(args) < 3) {
if len(args) < 2 || ((srcType == "https" || srcType == "http") && !turnOff && len(args) < 3) {
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
return flag.ErrHelp
}
srcPort, err := parsePort(srcPortStr)
if srcType == "https" && !turnOff {
// Running serve with https requires that the tailnet has enabled
// https cert provisioning. Send users through an interactive flow
// to enable this if not already done.
//
// TODO(sonia,tailscale/corp#10577): The interactive feature flow
// is behind a control flag. If the tailnet doesn't have the flag
// on, enableFeatureInteractive will error. For now, we hide that
// error and maintain the previous behavior (prior to 2023-08-15)
// of letting them edit the serve config before enabling certs.
e.enableFeatureInteractive(ctx, "serve", func(caps []string) bool {
return slices.Contains(caps, tailcfg.CapabilityHTTPS)
})
}
srcPort, err := parseServePort(srcPortStr)
if err != nil {
return err
return fmt.Errorf("invalid port %q: %w", srcPortStr, err)
}
switch srcType {
case "https":
case "https", "http":
mount, err := cleanMountPoint(args[1])
if err != nil {
return err
@@ -229,7 +268,8 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
if turnOff {
return e.handleWebServeRemove(ctx, srcPort, mount)
}
return e.handleWebServe(ctx, srcPort, mount, args[2])
useTLS := srcType == "https"
return e.handleWebServe(ctx, srcPort, useTLS, mount, args[2])
case "tcp", "tls-terminated-tcp":
if turnOff {
return e.handleTCPServeRemove(ctx, srcPort)
@@ -237,20 +277,20 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
return e.handleTCPServe(ctx, srcType, srcPort, args[1])
default:
fmt.Fprintf(os.Stderr, "error: invalid serve type %q\n", srcType)
fmt.Fprint(os.Stderr, "must be one of: https:<port>, tcp:<port> or tls-terminated-tcp:<port>\n\n", srcType)
fmt.Fprint(os.Stderr, "must be one of: http:<port>, https:<port>, tcp:<port> or tls-terminated-tcp:<port>\n\n", srcType)
return flag.ErrHelp
}
}
// handleWebServe handles the "tailscale serve https:..." subcommand.
// It configures the serve config to forward HTTPS connections to the
// given source.
// handleWebServe handles the "tailscale serve (http/https):..." subcommand. It
// configures the serve config to forward HTTPS connections to the given source.
//
// Examples:
// - tailscale serve http / http://localhost:3000
// - tailscale serve https / http://localhost:3000
// - tailscale serve https:8443 /files/ /home/alice/shared-files/
// - tailscale serve https:10000 /motd.txt text:"Hello, world!"
func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, source string) error {
func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bool, mount, source string) error {
h := new(ipn.HTTPHandler)
ts, _, _ := strings.Cut(source, ":")
@@ -309,7 +349,7 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, so
return flag.ErrHelp
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: true})
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
@@ -412,6 +452,7 @@ func cleanMountPoint(mount string) (string, error) {
if mount == "" {
return "", errors.New("mount point cannot be empty")
}
mount = cleanMinGWPathConversionIfNeeded(mount)
if !strings.HasPrefix(mount, "/") {
mount = "/" + mount
}
@@ -422,6 +463,26 @@ func cleanMountPoint(mount string) (string, error) {
return "", fmt.Errorf("invalid mount point %q", mount)
}
// cleanMinGWPathConversionIfNeeded strips the EXEPATH prefix from the given
// path if the path is a MinGW(ish) (Windows) shell arg.
//
// MinGW(ish) (Windows) shells perform POSIX-to-Windows path conversion
// converting the leading "/" of any shell arg to the EXEPATH, which mangles the
// mount point. Strip the EXEPATH prefix if it exists. #7963
//
// "/C:/Program Files/Git/foo" -> "/foo"
func cleanMinGWPathConversionIfNeeded(path string) string {
// Only do this on Windows.
if runtime.GOOS != "windows" {
return path
}
if _, ok := os.LookupEnv("MSYSTEM"); ok {
exepath := filepath.ToSlash(os.Getenv("EXEPATH"))
path = strings.TrimPrefix(path, exepath)
}
return path
}
func expandProxyTarget(source string) (string, error) {
if !strings.Contains(source, "://") {
source = "http://" + source
@@ -567,7 +628,13 @@ func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error {
// - tailscale status
// - tailscale status --json
func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
sc, err := e.lc.GetServeConfig(ctx)
var sc *ipn.ServeConfig
var err error
if e.memory {
sc, err = e.lc.GetMemoryServeConfig(ctx)
} else {
sc, err = e.lc.GetServeConfig(ctx)
}
if err != nil {
return err
}
@@ -585,7 +652,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
printf("No serve config\n")
return nil
}
st, err := e.getLocalClientStatus(ctx)
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return err
}
@@ -596,7 +663,10 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
printf("\n")
}
for hp := range sc.Web {
printWebStatusTree(sc, hp)
err := e.printWebStatusTree(sc, hp)
if err != nil {
return err
}
printf("\n")
}
printFunnelWarning(sc)
@@ -635,20 +705,37 @@ func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.S
return nil
}
func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) {
func (e *serveEnv) printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) error {
// No-op if no serve config
if sc == nil {
return
return nil
}
fStatus := "tailnet only"
if sc.AllowFunnel[hp] {
fStatus = "Funnel on"
}
host, portStr, _ := net.SplitHostPort(string(hp))
if portStr == "443" {
printf("https://%s (%s)\n", host, fStatus)
} else {
printf("https://%s:%s (%s)\n", host, portStr, fStatus)
port, err := parseServePort(portStr)
if err != nil {
return fmt.Errorf("invalid port %q: %w", portStr, err)
}
scheme := "https"
if sc.IsServingHTTP(port) {
scheme = "http"
}
portPart := ":" + portStr
if scheme == "http" && portStr == "80" ||
scheme == "https" && portStr == "443" {
portPart = ""
}
if scheme == "http" {
hostname, _, _ := strings.Cut(host, ".")
printf("%s://%s%s (%s)\n", scheme, hostname, portPart, fStatus)
}
printf("%s://%s%s (%s)\n", scheme, host, portPart, fStatus)
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
switch {
case h.Path != "":
@@ -675,6 +762,8 @@ func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) {
t, d := srvTypeAndDesc(h)
printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d)
}
return nil
}
func elipticallyTruncate(s string, max int) string {
@@ -683,3 +772,100 @@ func elipticallyTruncate(s string, max int) string {
}
return s[:max-3] + "..."
}
// runServeReset clears out the current serve config.
//
// Usage:
// - tailscale serve reset
func (e *serveEnv) runServeReset(ctx context.Context, args []string) error {
if len(args) != 0 {
return flag.ErrHelp
}
sc := new(ipn.ServeConfig)
return e.lc.SetServeConfig(ctx, sc)
}
// parseServePort parses a port number from a string and returns it as a
// uint16. It returns an error if the port number is invalid or zero.
func parseServePort(s string) (uint16, error) {
p, err := strconv.ParseUint(s, 10, 16)
if err != nil {
return 0, err
}
if p == 0 {
return 0, errors.New("port number must be non-zero")
}
return uint16(p), nil
}
// enableFeatureInteractive sends the node's user through an interactive
// flow to enable a feature, such as Funnel, on their tailnet.
//
// hasRequiredCapabilities should be provided as a function that checks
// whether a slice of node capabilities encloses the necessary values
// needed to use the feature.
//
// If err is returned empty, the feature has been successfully enabled.
//
// If err is returned non-empty, the client failed to query the control
// server for information about how to enable the feature.
//
// If the feature cannot be enabled, enableFeatureInteractive terminates
// the CLI process.
//
// 2023-08-09: The only valid feature values are "serve" and "funnel".
// This can be moved to some CLI lib when expanded past serve/funnel.
func (e *serveEnv) enableFeatureInteractive(ctx context.Context, feature string, hasRequiredCapabilities func(caps []string) bool) (err error) {
info, err := e.lc.QueryFeature(ctx, feature)
if err != nil {
return err
}
if info.Complete {
return nil // already enabled
}
if info.Text != "" {
fmt.Fprintln(os.Stdout, "\n"+info.Text)
}
if info.URL != "" {
fmt.Fprintln(os.Stdout, "\n "+info.URL+"\n")
}
if !info.ShouldWait {
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_not_awaiting_enablement", feature), 1)
// The feature has not been enabled yet,
// but the CLI should not block on user action.
// Once info.Text is printed, exit the CLI.
os.Exit(0)
}
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_awaiting_enablement", feature), 1)
// Block until feature is enabled.
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
watcher, err := e.lc.WatchIPNBus(watchCtx, 0)
if err != nil {
// If we fail to connect to the IPN notification bus,
// don't block. We still present the URL in the CLI,
// then close the process. Swallow the error.
log.Fatalf("lost connection to tailscaled: %v", err)
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
return err
}
defer watcher.Close()
for {
n, err := watcher.Next()
if err != nil {
// Stop blocking if we error.
// Let the user finish enablement then rerun their
// command themselves.
log.Fatalf("lost connection to tailscaled: %v", err)
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
return err
}
if nm := n.NetMap; nm != nil && nm.SelfNode.Valid() {
if hasRequiredCapabilities(nm.SelfNode.Capabilities().AsSlice()) {
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enabled", feature), 1)
fmt.Fprintln(os.Stdout, "Success.")
return nil
}
}
}
}

View File

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

View File

@@ -12,8 +12,10 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
"tailscale.com/safesocket"
"tailscale.com/types/views"
)
var setCmd = &ffcli.Command{
@@ -159,18 +161,18 @@ func runSet(ctx context.Context, args []string) (retErr error) {
// setArgs is the parsed command-line arguments.
func calcAdvertiseRoutesForSet(advertiseExitNodeSet, advertiseRoutesSet bool, curPrefs *ipn.Prefs, setArgs setArgsT) (routes []netip.Prefix, err error) {
if advertiseExitNodeSet && advertiseRoutesSet {
return calcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute)
return netutil.CalcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute)
}
if advertiseRoutesSet {
return calcAdvertiseRoutes(setArgs.advertiseRoutes, curPrefs.AdvertisesExitNode())
return netutil.CalcAdvertiseRoutes(setArgs.advertiseRoutes, curPrefs.AdvertisesExitNode())
}
if advertiseExitNodeSet {
alreadyAdvertisesExitNode := curPrefs.AdvertisesExitNode()
if alreadyAdvertisesExitNode == setArgs.advertiseDefaultRoute {
return curPrefs.AdvertiseRoutes, nil
}
routes = tsaddr.FilterPrefixesCopy(curPrefs.AdvertiseRoutes, func(p netip.Prefix) bool {
routes = tsaddr.FilterPrefixesCopy(views.SliceOf(curPrefs.AdvertiseRoutes), func(p netip.Prefix) bool {
return p.Bits() != 0
})
if setArgs.advertiseDefaultRoute {

View File

@@ -23,6 +23,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/interfaces"
"tailscale.com/util/cmpx"
"tailscale.com/util/dnsname"
)
@@ -200,6 +201,8 @@ func runStatus(ctx context.Context, args []string) error {
if statusArgs.self && st.Self != nil {
printPS(st.Self)
}
locBasedExitNode := false
if statusArgs.peers {
var peers []*ipnstate.PeerStatus
for _, peer := range st.Peers() {
@@ -207,6 +210,12 @@ func runStatus(ctx context.Context, args []string) error {
if ps.ShareeNode {
continue
}
if ps.Location != nil && ps.ExitNodeOption && !ps.ExitNode {
// Location based exit nodes are only shown with the
// `exit-node list` command.
locBasedExitNode = true
continue
}
peers = append(peers, ps)
}
ipnstate.SortPeers(peers)
@@ -218,6 +227,10 @@ func runStatus(ctx context.Context, args []string) error {
}
}
Stdout.Write(buf.Bytes())
if locBasedExitNode {
println()
println("# To see the full list of exit nodes, including location-based exit nodes, run `tailscale exit-node list` \n")
}
if len(st.Health) > 0 {
outln()
printHealth()
@@ -296,12 +309,20 @@ func dnsOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
}
func ownerLogin(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
if ps.UserID.IsZero() {
// We prioritize showing the name of the sharer as the owner of a node if
// it's different from the node's user. This is less surprising: if user B
// from a company shares user's C node from the same company with user A who
// don't know user C, user A might be surprised to see user C listed in
// their netmap. We've historically (2021-01..2023-08) always shown the
// sharer's name in the UI. Perhaps we want to show both here? But the CLI's
// a bit space constrained.
uid := cmpx.Or(ps.AltSharerUserID, ps.UserID)
if uid.IsZero() {
return "-"
}
u, ok := st.User[ps.UserID]
u, ok := st.User[uid]
if !ok {
return fmt.Sprint(ps.UserID)
return fmt.Sprint(uid)
}
if i := strings.Index(u.LoginName, "@"); i != -1 {
return u.LoginName[:i+1]

View File

@@ -6,18 +6,19 @@ package cli
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/netip"
"net/url"
"os"
"os/signal"
"reflect"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
@@ -26,10 +27,12 @@ import (
shellquote "github.com/kballard/go-shellquote"
"github.com/peterbourgon/ff/v3/ffcli"
qrcode "github.com/skip2/go-qrcode"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/health/healthmsg"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsaddr"
"tailscale.com/net/netutil"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
@@ -87,8 +90,6 @@ func acceptRouteDefault(goos string) bool {
var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgsGlobal, "up")
func inTest() bool { return flag.Lookup("test.v") != nil }
// newUpFlagSet returns a new flag set for the "up" and "login" commands.
func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
if cmd != "up" && cmd != "login" {
@@ -218,82 +219,6 @@ func warnf(format string, args ...any) {
printf("Warning: "+format+"\n", args...)
}
var (
ipv4default = netip.MustParsePrefix("0.0.0.0/0")
ipv6default = netip.MustParsePrefix("::/0")
)
func validateViaPrefix(ipp netip.Prefix) error {
if !tsaddr.IsViaPrefix(ipp) {
return fmt.Errorf("%v is not a 4-in-6 prefix", ipp)
}
if ipp.Bits() < (128 - 32) {
return fmt.Errorf("%v 4-in-6 prefix must be at least a /%v", ipp, 128-32)
}
a := ipp.Addr().As16()
// The first 64 bits of a are the via prefix.
// The next 32 bits are the "site ID".
// The last 32 bits are the IPv4.
// For now, we reserve the top 3 bytes of the site ID,
// and only allow users to use site IDs 0-255.
siteID := binary.BigEndian.Uint32(a[8:12])
if siteID > 0xFF {
return fmt.Errorf("route %v contains invalid site ID %08x; must be 0xff or less", ipp, siteID)
}
return nil
}
func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]netip.Prefix, error) {
routeMap := map[netip.Prefix]bool{}
if advertiseRoutes != "" {
var default4, default6 bool
advroutes := strings.Split(advertiseRoutes, ",")
for _, s := range advroutes {
ipp, err := netip.ParsePrefix(s)
if err != nil {
return nil, fmt.Errorf("%q is not a valid IP address or CIDR prefix", s)
}
if ipp != ipp.Masked() {
return nil, fmt.Errorf("%s has non-address bits set; expected %s", ipp, ipp.Masked())
}
if tsaddr.IsViaPrefix(ipp) {
if err := validateViaPrefix(ipp); err != nil {
return nil, err
}
}
if ipp == ipv4default {
default4 = true
} else if ipp == ipv6default {
default6 = true
}
routeMap[ipp] = true
}
if default4 && !default6 {
return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv4default, ipv6default)
} else if default6 && !default4 {
return nil, fmt.Errorf("%s advertised without its IPv4 counterpart, please also advertise %s", ipv6default, ipv4default)
}
}
if advertiseDefaultRoute {
routeMap[netip.MustParsePrefix("0.0.0.0/0")] = true
routeMap[netip.MustParsePrefix("::/0")] = true
}
if len(routeMap) == 0 {
return nil, nil
}
routes := make([]netip.Prefix, 0, len(routeMap))
for r := range routeMap {
routes = append(routes, r)
}
sort.Slice(routes, func(i, j int) bool {
if routes[i].Bits() != routes[j].Bits() {
return routes[i].Bits() < routes[j].Bits()
}
return routes[i].Addr().Less(routes[j].Addr())
})
return routes, nil
}
// prefsFromUpArgs returns the ipn.Prefs for the provided args.
//
// Note that the parameters upArgs and warnf are named intentionally
@@ -301,7 +226,7 @@ func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]
// function exists for testing and should have no side effects or
// outside interactions (e.g. no making Tailscale LocalAPI calls).
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
routes, err := calcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
routes, err := netutil.CalcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
if err != nil {
return nil, err
}
@@ -420,7 +345,7 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
simpleUp = env.flagSet.NFlag() == 0 &&
curPrefs.Persist != nil &&
curPrefs.Persist.LoginName != "" &&
curPrefs.Persist.UserProfile.LoginName != "" &&
env.backendState != ipn.NeedsLogin.String()
justEdit := env.backendState == ipn.Running.String() &&
@@ -663,6 +588,10 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
if err != nil {
return err
}
authKey, err = resolveAuthKey(ctx, authKey, upArgs.advertiseTags)
if err != nil {
return err
}
if err := localClient.Start(ctx, ipn.Options{
AuthKey: authKey,
UpdatePrefs: prefs,
@@ -717,7 +646,8 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
// the health check, rather than just a string.
func upWorthyWarning(s string) bool {
return strings.Contains(s, healthmsg.TailscaleSSHOnBut) ||
strings.Contains(s, healthmsg.WarnAcceptRoutesOff)
strings.Contains(s, healthmsg.WarnAcceptRoutesOff) ||
strings.Contains(s, healthmsg.LockedOut)
}
func checkUpWarnings(ctx context.Context) {
@@ -1102,3 +1032,93 @@ func anyPeerAdvertisingRoutes(st *ipnstate.Status) bool {
}
return false
}
func init() {
// Required to use our client API. We're fine with the instability since the
// client lives in the same repo as this code.
tailscale.I_Acknowledge_This_API_Is_Unstable = true
}
// resolveAuthKey either returns v unchanged (in the common case) or, if it
// starts with "tskey-client-" (as Tailscale OAuth secrets do) parses it like
//
// tskey-client-xxxx[?ephemeral=false&bar&preauthorized=BOOL&baseURL=...]
//
// and does the OAuth2 dance to get and return an authkey. The "ephemeral"
// property defaults to true if unspecified. The "preauthorized" defaults to
// false. The "baseURL" defaults to https://api.tailscale.com.
// The passed in tags are required, and must be non-empty. These will be
// set on the authkey generated by the OAuth2 dance.
func resolveAuthKey(ctx context.Context, v, tags string) (string, error) {
if !strings.HasPrefix(v, "tskey-client-") {
return v, nil
}
if tags == "" {
return "", errors.New("oauth authkeys require --advertise-tags")
}
clientSecret, named, _ := strings.Cut(v, "?")
attrs, err := url.ParseQuery(named)
if err != nil {
return "", err
}
for k := range attrs {
switch k {
case "ephemeral", "preauthorized", "baseURL":
default:
return "", fmt.Errorf("unknown attribute %q", k)
}
}
getBool := func(name string, def bool) (bool, error) {
v := attrs.Get(name)
if v == "" {
return def, nil
}
ret, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("invalid attribute boolean attribute %s value %q", name, v)
}
return ret, nil
}
ephemeral, err := getBool("ephemeral", true)
if err != nil {
return "", err
}
preauth, err := getBool("preauthorized", false)
if err != nil {
return "", err
}
baseURL := "https://api.tailscale.com"
if v := attrs.Get("baseURL"); v != "" {
baseURL = v
}
credentials := clientcredentials.Config{
ClientID: "some-client-id", // ignored
ClientSecret: clientSecret,
TokenURL: baseURL + "/api/v2/oauth/token",
Scopes: []string{"device"},
}
tsClient := tailscale.NewClient("-", nil)
tsClient.HTTPClient = credentials.Client(ctx)
tsClient.BaseURL = baseURL
caps := tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
Create: tailscale.KeyDeviceCreateCapabilities{
Reusable: false,
Ephemeral: ephemeral,
Preauthorized: preauth,
Tags: strings.Split(tags, ","),
},
},
}
authkey, _, err := tsClient.CreateKey(ctx, caps)
if err != nil {
return "", err
}
return authkey, nil
}

View File

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

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