Compare commits

...

128 Commits

Author SHA1 Message Date
Andrea Gottardo
025ceed735 cli: implement tailscale dns stream
Updates tailscale/tailscale#13326

This PR adds another subcommand to `tailscale dns`, to stream queries and answers returned by the DNS forwarder as they are handled.

Useful for debugging purposes, and is equivalent to setting the `TS_DEBUG_DNS_FORWARD_SEND` envknob and filtering the logs for relevant entries. This also adds a new envknob, `TS_DEBUG_DNS_INCLUDE_NAMES`, which includes the actual hostnames in the log lines (with a huge privacy warning!). This makes it easier to diagnose issues with DNS resolution.
2024-09-19 15:52:31 -07:00
Andrea Gottardo
7c02dcf93a cli: add tailscale dns query
Updates tailscale/tailscale#13326

Adds a CLI subcommand to perform DNS queries using the internal DNS forwarder and observe its internals (namely, which upstream resolvers are being used).

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-09-19 14:40:29 -07:00
Tom Proctor
d0a56a8870 cmd/containerboot: split main.go (#13517)
containerboot's main.go had grown to well over 1000 lines with
lots of disparate bits of functionality. This commit is pure copy-
paste to group related functionality outside of the main function
into its own set of files. Everything is still in the main package
to keep the diff incremental and reviewable.

Updates #cleanup

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-09-19 17:58:26 +01:00
James Tucker
af5a845a87 net/dns/resolver: fix dns-sd NXDOMAIN responses from quad-100
mdnsResponder at least as of macOS Sequoia does not find NXDOMAIN
responses to these dns-sd PTR queries acceptable unless they include the
question section in the response. This was found debugging #13511, once
we turned on additional diagnostic reporting from mdnsResponder we
witnessed:

```
Received unacceptable 12-byte response from 100.100.100.100 over UDP via utun6/27 -- id: 0x7F41 (32577), flags: 0x8183 (R/Query, RD, RA, NXDomain), counts: 0/0/0/0,
```

If the response includes a question section, the resposnes are
acceptable, e.g.:

```
Received acceptable 59-byte response from 8.8.8.8 over UDP via en0/17 -- id: 0x2E55 (11861), flags: 0x8183 (R/Query, RD, RA, NXDomain), counts: 1/0/0/0,
```

This may be contributing to an issue under diagnosis in #13511 wherein
some combination of conditions results in mdnsResponder no longer
answering DNS queries correctly to applications on the system for
extended periods of time (multiple minutes), while dig against quad-100
provides correct responses for those same domains. If additional debug
logging is enabled in mdnsResponder we see it reporting:

```
Penalizing server 100.100.100.100 for 60 seconds
```

It is also possible that the reason that macOS & iOS never "stopped
spamming" these queries is that they have never been replied to with
acceptable responses. It is not clear if this special case handling of
dns-sd PTR queries was ever beneficial, and given this evidence may have
always been harmful. If we subsequently observe that the queries settle
down now that they have acceptable responses, we should remove these
special cases - making upstream queries very occasionally isn't a lot of
battery, so we should be better off having to maintain less special
cases and avoid bugs of this class.

Updates #2442
Updates #3025
Updates #3363
Updates #3594
Updates #13511

Signed-off-by: James Tucker <james@tailscale.com>
2024-09-18 18:43:03 -07:00
Andrea Gottardo
3a467b66b6 go/toolchain: use ed9dc37b2b000f376a3e819cbb159e2c17a2dac6 (#13507)
Updates tailscale/tailscale#13452

Bump the Go toolchain to the latest to pick up changes required to not crash on Android 9/10.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-09-18 18:51:09 +00:00
M. J. Fromberger
5f89c93274 safeweb: add a ListenAndServe method to the Server type (#13498)
Updates #13497

Change-Id: I398e9fa58ad0b9dc799ea280c9c7a32150150ee4
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
2024-09-17 12:59:28 -07:00
Jordan Whited
951884b077 net/netcheck,wgengine/magicsock: plumb OnlyTCP443 controlknob through netcheck (#13491)
Updates tailscale/corp#17879

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-17 12:24:42 -07:00
Fran Bull
8b962f23d1 cmd/natc: fix nil pointer
Fixes #13495

Signed-off-by: Fran Bull <fran@tailscale.com>
2024-09-17 09:48:48 -07:00
Jordan Whited
5f4a4c6744 wgengine/magicsock: fix sendUDPStd docs (#13490)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-16 19:28:00 -07:00
Jordan Whited
4084c6186d wgengine/magicsock: add side-effect-free function for netcheck UDP sends (#13487)
Updates #13484
Updates tailscale/corp#17879

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-16 19:00:12 -07:00
Brad Fitzpatrick
8012bb4216 derp: refactor DERP server's peer-gone watch mechanism
In prep for upcoming flow tracking & mutex contention optimization
changes, this change refactors (subjectively simplifying) how the DERP
Server accounts for which peers have written to which other peers, to
be able to send PeerGoneReasonDisconnected messages to writes to
uncache their DRPO (DERP Return Path Optimization) routes.

Notably, this removes the Server.sentTo field which was guarded by
Server.mu and checked on all packet sends. Instead, the accounting is
moved to each sclient's sendLoop goroutine and now only needs to
acquire Server.mu for newly seen senders, the first time a peer sends
a packet to that sclient.

This change reduces the number of reasons to acquire Server.mu
per-packet from two to one. Removing the last one is the subject of an
upcoming change.

Updates #3560
Updates #150

Change-Id: Id226216d6629d61254b6bfd532887534ac38586c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-16 17:47:38 -07:00
License Updater
7f1c193a83 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-09-16 15:21:37 -07:00
Andrew Dunham
f572286bf9 gokrazy, various: use point versions of Go and update Nix deps
This un-breaks vim-go (which doesn't understand "go 1.23") and allows
the natlab tests to work in a Nix shell (by adding the "qemu-img" and
"mkfs.ext4" binaries to the shell). These binaries are available even on
macOS, as I'm testing on my M1 Max.

Updates #13038

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I99f8521b5de93ea47dc33b099d5b243ffc1303da
2024-09-16 16:06:43 -04:00
Andrew Dunham
40833a7524 wgengine/magicsock: disable raw disco by default; add envknob to enable
Updates #13140

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ica85b2ac8ac7eab4ec5413b212f004aecc453279
2024-09-16 11:06:33 -07:00
Mario Minardi
124ff3b034 {api.md,publicapi}: remove old API docs (#13468)
Now that we have our API docs hosted at https://tailscale.com/api we can
remove the previous (and now outdated) markdown based docs. The top
level api.md has been left with the only content being the redirect to
the new docs.

Updates #cleanup

Signed-off-by: Mario Minardi <mario@tailscale.com>
2024-09-13 14:10:33 -06:00
Jordan Whited
afec2d41b4 wgengine/magicsock: remove redundant deadline from netcheck report call (#13395)
netcheck.Client.GetReport() applies its own deadlines. This 2s deadline
was causing GetReport() to never fall back to HTTPS/ICMP measurements
as it was shorter than netcheck.stunProbeTimeout, leaving no time
for fallbacks.

Updates #13394
Updates #6187

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-13 10:51:30 -07:00
Mario Minardi
93f61aa4cc tailcfg: add node attr for SSH environment variables (#13450)
Add a node attr for enabling SSH environment variable handling logic.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2024-09-12 16:18:14 -06:00
Brad Fitzpatrick
aa15a63651 derp: add new concurrent server benchmark
In prep for reducing mutex contention on Server.mu.

Updates #3560

Change-Id: Ie95e7c6dc9f4b64b6f79b3b2338f8cd86c688d98
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-12 14:05:38 -07:00
kari-ts
3bee38d50f VERSION.txt: this is v1.75.0 (#13454)
Signed-off-by: kari-ts <kari@tailscale.com>
2024-09-12 20:19:46 +00:00
Brad Fitzpatrick
cec779e771 util/slicesx: add FirstElementEqual and LastElementEqual
And update a few callers as examples of motivation. (there are a
couple others, but these are the ones where it's prettier)

Updates #cleanup

Change-Id: Ic8c5cb7af0a59c6e790a599136b591ebe16d38eb
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-11 18:36:00 -07:00
Brad Fitzpatrick
910462a8e0 derp: unify server's clientSet interface into concrete type
73280595a8 for #2751 added a "clientSet" interface to
distinguish the two cases of a client being singly connected (the
common case) vs tolerating multiple connections from the client at
once. At the time (three years ago) it was kinda an experiment
and we didn't know whether it'd stop the reconnect floods we saw
from certain clients. It did.

So this promotes it to a be first-class thing a bit, removing the
interface. The old tests from 73280595a were invaluable in ensuring
correctness while writing this change (they failed a bunch).

But the real motivation for this change is that it'll permit a future
optimization to add flow tracking for stats & performance where we
don't contend on Server.mu for each packet sent via DERP. Instead,
each client can track its active flows and hold on to a *clientSet and
ask the clientSet per packet what the active client is via one atomic
load rather than a mutex. And if the atomic load returns nil, we'll
know we need to ask the server to see if they died and reconnected and
got a new clientSet. But that's all coming later.

Updates #3560

Change-Id: I9ccda3e5381226563b5ec171ceeacf5c210e1faf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-11 16:17:27 -07:00
Maisem Ali
f2713b663e .github: enable fuzz testing again (go1.23)
Updates #12912

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-09-11 14:50:13 -07:00
Maisem Ali
4d6a8224d5 util/linuxfw: fall back to nftables when iptables not found
When the desired netfilter mode was unset, we would always try
to use the `iptables` binary. In such cases if iptables was not found,
tailscaled would just crash as seen in #13440. To work around this, in those
cases check if the `iptables` binary even exists and if it doesn't fall back
to the nftables implementation.

Verified that it works on stock Ubuntu 24.04.

Updates #5621
Updates #8555
Updates #8762
Fixes #13440

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-09-11 14:36:17 -07:00
Tom Proctor
98f4dd9857 cmd/k8s-operator,k8s-operator,kube: Add TSRecorder CRD + controller (#13299)
cmd/k8s-operator,k8s-operator,kube: Add TSRecorder CRD + controller

Deploys tsrecorder images to the operator's cluster. S3 storage is
configured via environment variables from a k8s Secret. Currently
only supports a single tsrecorder replica, but I've tried to take early
steps towards supporting multiple replicas by e.g. having a separate
secret for auth and state storage.

Example CR:

```yaml
apiVersion: tailscale.com/v1alpha1
kind: Recorder
metadata:
  name: rec
spec:
  enableUI: true
```

Updates #13298

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-09-11 12:19:29 +01:00
Brad Fitzpatrick
9f9470fc10 ipnlocal,proxymap,wgengine/netstack: add optional WhoIs/proxymap debug
Updates tailscale/corp#20600

Change-Id: I2bb17af0f40603ada1ba4cecc087443e00f9392a
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-10 14:23:33 -07:00
Fran Bull
7d16af8d95 cmd/natc: fix nil pointer
Fixes #13432

Signed-off-by: Fran Bull <fran@tailscale.com>
2024-09-10 13:49:29 -07:00
dependabot[bot]
436a0784a2 build(deps): bump ws from 8.14.2 to 8.17.1 in /client/web (#12524)
Bumps [ws](https://github.com/websockets/ws) from 8.14.2 to 8.17.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.14.2...8.17.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-10 12:39:40 -06:00
dependabot[bot]
71b550c73c .github: Bump peter-evans/create-pull-request from 5.0.1 to 7.0.1 (#13419)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 5.0.1 to 7.0.1.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](284f54f989...8867c4aba1)

---
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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-10 10:08:14 -06:00
Jordan Whited
a228d77f86 cmd/stunstamp: add protocol context to timeout logs (#13422)
We started out with a single protocol & port, now it's many.

Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-09 18:42:13 -07:00
Andrew Dunham
0970615b1b ipn/ipnlocal: don't program system DNS when node key is expired (#13370)
This mimics having Tailscale in the 'Stopped' state by programming an
empty DNS configuration when the current node key is expired.

Updates tailscale/support-escalations#55


Change-Id: I68ff4665761fb621ed57ebf879263c2f4b911610

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2024-09-09 15:15:29 -04:00
Brad Fitzpatrick
0a2e5afb26 tsnet: remove old package doc experimental warning
It was scaring people. It's been pretty stable for quite some time now
and we're unlikely to change the API and break people at this point.
We might, but have been trying not to.

Fixes tailscale/corp#22933

Change-Id: I0c3c79b57ccac979693c62ba320643a940ac947e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-09 09:40:43 -07:00
Irbe Krumina
209567e7a0 kube,cmd/{k8s-operator,containerboot},envknob,ipn/store/kubestore,*/depaware.txt: rename packages (#13418)
Rename kube/{types,client,api} -> kube/{kubetypes,kubeclient,kubeapi}
so that we don't need to rename the package on each import to
convey that it's kubernetes specific.

Updates#cleanup

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-09-08 20:57:29 +01:00
Irbe Krumina
d6dfb7f242 kube,cmd/{k8s-operator,containerboot},envknob,ipn/store/kubestore,*/depaware.txt: split out kube types (#13417)
Further split kube package into kube/{client,api,types}. This is so that
consumers who only need constants/static types don't have to import
the client and api bits.

Updates#cleanup

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-09-08 19:06:07 +01:00
Irbe Krumina
ecd64f6ed9 cmd/k8s-operator,kube: set app name for Kubernetes Operator proxies (#13410)
Updates tailscale/corp#22920

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-09-08 05:48:38 +01:00
Nick Khyl
4dfde7bffc net/dns: disable DNS registration for Tailscale interface on Windows
We already disable dynamic updates by setting DisableDynamicUpdate to 1 for the Tailscale interface.
However, this does not prevent non-dynamic DNS registration from happening when `ipconfig /registerdns`
runs and in similar scenarios. Notably, dns/windowsManager.SetDNS runs `ipconfig /registerdns`,
triggering DNS registration for all interfaces that do not explicitly disable it.

In this PR, we update dns/windowsManager.disableDynamicUpdates to also set RegistrationEnabled to 0.

Fixes #13411

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-09-07 19:00:38 +01:00
Irbe Krumina
2b0d0ddf5d sessionrecording,ssh/tailssh,k8s-operator: log connected recorder address (#13382)
Updates tailscale/corp#19821

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-09-07 06:11:33 +01:00
Patrick O'Doherty
7ce9c1944a go.toolchain.rev: update to 1.23.1 (#13408)
Update Go toolchain to 1.23.1.

Updates #cleanup

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2024-09-06 13:09:15 -07:00
Brad Fitzpatrick
71ff3d7c39 go.mod: bump github.com/illarion/gonotify/v2
Updates #13359

Change-Id: I28e048bf9d1d114d07d140f165f4ea89a82be79f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-06 08:36:10 -07:00
Jordan Whited
95f0094310 cmd/stunstamp: cleanup timeout and interval constants (#13393)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-05 13:40:12 -07:00
Nick Khyl
e7b5e8c8cd ipn/ipnserver: remove IdleTimeout
We no longer need this on Windows, and it was never required on other platforms.
It just results in more short-lived connections unless we use HTTP/2.

Updates tailscale/corp#18342

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-09-05 13:00:38 -05:00
Andrew Lytvynov
e7a6e7930f cmd/systray: handle reconnects to IPN bus (#13386)
When tailscaled restarts and our watch connection goes down, we get
stuck in an infinite loop printing `ipnbus error: EOF` (which ended up
consuming all the disk space on my laptop via the log file). Instead,
handle errors in `watchIPNBus` and reconnect after a short delay.

Updates #1708

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-09-05 10:11:05 -07:00
Flakes Updater
4f2a2bfa42 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-09-05 10:06:02 -07:00
Jordan Whited
7aa766ee65 net/tstun: probe TCP GRO (#13376)
Disable TCP & UDP GRO if the probe fails.

torvalds/linux@e269d79c7d broke virtio_net
TCP & UDP GRO causing GRO writes to return EINVAL. The bug was then
resolved later in
torvalds/linux@89add40066. The offending
commit was pulled into various LTS releases.

Updates #13041

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-05 09:59:31 -07:00
Andrew Dunham
7dcf65a10a net/dns: fix IsZero and Equal methods on OSConfig
Discovered this while investigating the following issue; I think it's
unrelated, but might as well fix it. Also, add a test helper for
checking things that have an IsZero method using the reflect package.

Updates tailscale/support-escalations#55

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I57b7adde43bcef9483763b561da173b4c35f49e2
2024-09-05 00:05:36 -04:00
Brad Fitzpatrick
13dee9db7b health: fix magicsockReceiveFuncWarnable health clearing
Fixes #13204

Change-Id: I7154cdabc9dc362dcc3221fd5a86e21f610bbff0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-04 17:08:33 -07:00
Brad Fitzpatrick
3d401c11fa all: use new Go 1.23 slices.Sorted more
Updates #12912

Change-Id: If1294e5bc7b5d3cf0067535ae10db75e8b988d8b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-04 14:52:21 -07:00
Anton Tolchanov
fd6686d81a tka: truncate long rotation signature chains
When a rotation signature chain reaches a certain size, remove the
oldest rotation signature from the chain before wrapping it in a new
rotation signature.

Since all previous rotation signatures are signed by the same wrapping
pubkey (node's own tailnet lock key), the node can re-construct the
chain, re-signing previous rotation signatures. This will satisfy the
existing certificate validation logic.

Updates #13185

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-09-04 22:17:21 +01:00
Brad Fitzpatrick
bcc47d91ca cmd/tailscale/cli: use new Go 1.23 slices.Sorted
And a grammatical nit.

Updates #12912

Change-Id: I9feae53beb4d28dfe98b583373e2e0a43c801fc4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-04 13:27:05 -07:00
Nick Khyl
11d205f6c4 control/controlclient,posture,util/syspolicy: use predefined syspolicy keys instead of string literals
With the upcoming syspolicy changes, it's imperative that all syspolicy keys are defined in the syspolicy package
for proper registration. Otherwise, the corresponding policy settings will not be read.

This updates a couple of places where we still use string literals rather than syspolicy consts.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-09-04 15:25:19 -05:00
Andrea Gottardo
d060b3fa02 cli: implement tailscale dns status (#13353)
Updates tailscale/tailscale#13326

This PR begins implementing a `tailscale dns` command group in the Tailscale CLI. It provides an initial implementation of `tailscale dns status` which dumps the state of the internal DNS forwarder.

Two new endpoints were added in LocalAPI to support the CLI functionality:

- `/netmap`: dumps a copy of the last received network map (because the CLI shouldn't have to listen to the ipn bus for a copy)
- `/dns-osconfig`: dumps the OS DNS configuration (this will be very handy for the UI clients as well, as they currently do not display this information)

My plan is to implement other subcommands mentioned in tailscale/tailscale#13326, such as `query`, in later PRs.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-09-04 19:43:55 +00:00
Nick Khyl
5bc9fafab8 ipn/ipnlocal: always send auth URL notifications when a user requests interactive login
This PR changes how LocalBackend handles interactive (initiated via StartLoginInteractive) and non-interactive (e.g., due to key expiration) logins,
and when it sends the authURL to the connected clients.

Specifically,
 - When a user initiates an interactive login by clicking Log In in the GUI, the LocalAPI calls StartLoginInteractive.
   If an authURL is available and hasn't expired, we immediately send it to all connected clients, suggesting them to open that URL in a browser.
   Otherwise, we send a login request to the control plane and set a flag indicating that an interactive login is in progress.
 - When LocalBackend receives an authURL from the control plane, we check if it differs from the previous one and whether an interactive login
   is in progress. If either condition is true, we notify all connected clients with the new authURL and reset the interactive login flag.

We reset the auth URL and flags upon a successful authentication, when a different user logs in and when switching Tailscale login profiles.

Finally, we remove the redundant dedup logic added to WatchNotifications in #12096 and revert the tests to their original state to ensure that
calling StartLoginInteractive always produces BrowseToURL notifications, either immediately or when the authURL is received from the control plane.

Fixes #13296

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-09-04 13:39:46 -05:00
Andrea Gottardo
0112da6070 net/dns: support GetBaseConfig on Darwin OSS tailscaled (#13351)
Updates tailscale/tailscale#177

It appears that the OSS distribution of `tailscaled` is currently unable to get the current system base DNS configuration, as GetBaseConfig() in manager_darwin.go is unimplemented. This PR adds a basic implementation that reads the current values in `/etc/resolv.conf`, to at least unblock DNS resolution via Quad100 if `--accept-dns` is enabled.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-09-04 10:31:58 -07:00
Jordan Whited
1fc4268aea cmd/stunstamp: increase probe jitter (#13362)
We've added more probe targets recently which has resulted in more
timeouts behind restrictive NATs in localized testing that don't
like how many flows we are creating at once. Not so much an issue
for datacenter or cloud-hosted deployments.

Updates tailscale/corp#22114

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-04 09:54:32 -07:00
Jordan Whited
1dd1798bfa cmd/stunstamp: use measureFn more consistently in naming/signatures (#13360)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-04 09:28:03 -07:00
Jordan Whited
6d6b1773ea cmd/stunstamp: implement ICMP{v6} probing (#13354)
This adds both userspace and kernel timestamping.

Updates tailscale/corp#22114

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-09-04 08:36:47 -07:00
Brad Fitzpatrick
c4d0237e5c tstest/natlab: add dual stack with blackholed IPv4
This reproduces the bug report from
https://github.com/tailscale/tailscale/issues/13346

It does not yet fix it.

Updates #13346

Change-Id: Ia5af7b0481a64a37efe259c798facdda6d9da618
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-03 17:16:26 -07:00
Nick Khyl
aeb15dea30 util/syspolicy/source: add package for reading policy settings from external stores
We add package defining interfaces for policy stores, enabling creation of policy sources
and reading settings from them. It includes a Windows-specific PlatformPolicyStore for GP and MDM
policies stored in the Registry, and an in-memory TestStore for testing purposes.

We also include an internal package that tracks and reports policy usage metrics when a policy setting
is read from a store. Initially, it will be used only on Windows and Android, as macOS, iOS, and tvOS
report their own metrics. However, we plan to use it across all platforms eventually.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-09-03 14:51:14 -05:00
Brad Fitzpatrick
e865a0e2b0 cmd/tailscale/cli: add 'debug go-buildinfo' subcommand
To dump runtime/debug.BuildInfo.

Updates #1866

Change-Id: I8810390858a03b7649f9b22ef3ab910d423388da
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-03 11:37:04 -07:00
Seaver Thorn
345876da33 client/tailscale: adding missing proto field in ACL parsing structures (#13051)
Signed-off-by: Seaver Thorn <swthorn@ncsu.edu>
2024-09-03 18:04:39 +00:00
Irbe Krumina
8e1c00f841 cmd/k8s-operator,k8s-operator/sessionrecording: ensure recording header contains terminal size for terminal sessions (#12965)
* cmd/k8s-operator,k8s-operator/sessonrecording: ensure CastHeader contains terminal size

For tsrecorder to be able to play session recordings, the recording's
CastHeader must have '.Width' and '.Height' fields set to non-zero.
Kubectl (or whoever is the client that initiates the 'kubectl exec'
session recording) sends the terminal dimensions in a resize message that
the API server proxy can intercept, however that races with the first server
message that we need to record.
This PR ensures we wait for the terminal dimensions to be processed from
the first resize message before any other data is sent, so that for all
sessions with terminal attached, the header of the session recording
contains the terminal dimensions and the recording can be played by tsrecorder.

Updates tailscale/tailscale#19821

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-09-03 18:42:02 +01:00
Andrew Dunham
1c972bc7cb wgengine/magicsock: actually use AF_PACKET socket for raw disco
Previously, despite what the commit said, we were using a raw IP socket
that was *not* an AF_PACKET socket, and thus was subject to the host
firewall rules. Switch to using a real AF_PACKET socket to actually get
the functionality we want.

Updates #13140

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: If657daeeda9ab8d967e75a4f049c66e2bca54b78
2024-09-03 12:50:09 -04:00
Brad Fitzpatrick
eb2fa16fcc tailcfg: bump capver for earlier cryptokey panic fix [capver 106]
I should've bumped capver in 65fe0ba7b5 but forgot.

This lets us turn off the cryptokey routing change from control for
the affected panicky range of commits, based on capver.

Updates #13332
Updates tailscale/corp#20732

Change-Id: I32c17cfcb45b2369b2b560032330551d47a0ce0b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-03 09:36:47 -07:00
Brad Fitzpatrick
20cf48b8dd gokrazy{,/natlabapp.arm64}: start adding arm64 appliance support
Both for Raspberry Pis, and for running natlab tests faster on Apple
Silicon Macs without emulating x86.

Not fully wired up yet.

Updates #1866
Updates #13038

Change-Id: I1552bf107069308f325f640773cc881ed735b5ab
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-09-03 09:24:15 -07:00
Brad Fitzpatrick
65fe0ba7b5 wgengine/magicsock: fix panic regression from cryptokey routing change
Fixes #13332
Updates tailscale/corp#20732

Change-Id: I30f12746844bf77f5a664bf8e8d8ebf2511a2b27
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-31 06:19:28 -07:00
Nick Khyl
2f2aeaeaeb ipn/ipnlocal: fix a nil pointer dereference when serving /localapi/v0/tka/status
Fixes #13330

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-30 23:13:44 -05:00
Brad Fitzpatrick
3d9e3a17fa tstest/natlab/vnet: move some boilerplate to mkPacket helper
No need to make callers specify the redundant IP version or
TTL/HopLimit or EthernetType in the common case. The mkPacket helper
can set those when unset.

And use the mkIPLayer in another place, simplifying some code.

And rename mkPacketErr to just mkPacket, then move mkPacket to
test-only code, as mustPacket.

Updates #13038

Change-Id: Ic216e44dda760c69ab9bfc509370040874a47d30
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-30 20:23:30 -07:00
Brad Fitzpatrick
7e88d6712e tstest/natlab/vnet: add syslog tests
Updates #13038

Change-Id: I4ac96cb0a9e46a2fb1e09ddedd3614eb006c2c8c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-30 14:22:01 -07:00
Brad Fitzpatrick
b1a5b40318 tstest/natlab/vnet: add DHCP tests, ignore DHCPv4 on v6-only networks
And clean up some of the test helpers in the process.

Updates #13038

Change-Id: I3e2b5f7028a32d97af7f91941e59399a8e222b25
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-30 08:49:01 -07:00
Brad Fitzpatrick
ffa1c93f59 tstest/natlab/vnet: use mkPacketErr in more places
I'd added this helper for tests, but then moved it to non-test code
and forgot some places to use it. This uses it in more places to
remove some boilerplate.

Updates #13038

Change-Id: Ic4dc339be1c47a55b71d806bab421097ee3d75ed
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-30 08:49:01 -07:00
Anton Tolchanov
109d0891e1 posture: stop logging serial numbers
Logging serial numbers every time they are read might have been useful
early on, but seems unnecessary now.

Updates #5902

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-30 15:45:53 +01:00
Nick Khyl
959285e0c5 ipn/ipnlocal: fix race condition that results in a panic sending on a closed channel
Fixes #13288

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-29 17:27:21 -05:00
Percy Wegmann
35423fcf69 drive/driveimpl: use su instead of sudo
This allows Taildrive to work on systems like Busybox that don't have sudo.

Fixes #12282

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-08-29 16:23:03 -05:00
Jordan Whited
45c97751fb net/tstun: clarify GROFilterFunc *gro.GRO usage (#13318)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-29 13:04:46 -07:00
Percy Wegmann
ecc451501c ssh/tailssh: add ability to force V2 behavior using new feature flag
Introduces ssh-behavior-v2 node attribute to override ssh-behavior-v1.

Updates #11854

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-08-29 15:02:58 -05:00
Andrea Gottardo
a584d04f8a dns: increase TimeToVisible before DNS unavailable warning (#13317)
Updates tailscale/tailscale#13314

Some users are reporting 'DNS unavailable' spurious (?) warnings, especially on Android:

https://old.reddit.com/r/Tailscale/comments/1f2ow3w/health_warning_dns_unavailable_on_tailscale/
https://old.reddit.com/r/Tailscale/comments/1f3l2il/health_warnings_dns_unavailable_what_does_it_mean/

I suspect this is caused by having a too low TimeToVisible setting on the Warnable, which triggers the unhealthy state during slow network transitions.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-08-29 11:43:38 -07:00
Jordan Whited
0926954cf5 net/tstun,wgengine/netstack: implement TCP GRO for local services (#13315)
Throughput improves substantially when measured via netstack loopback
(TS_DEBUG_NETSTACK_LOOPBACK_PORT).

Before (d21ebc2):
jwhited@i5-12400-2:~$ iperf3 -V -c 100.100.100.100
Starting Test: protocol: TCP, 1 streams, 131072 byte blocks
Test Complete. Summary Results:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  5.77 GBytes  4.95 Gbits/sec    0 sender
[  5]   0.00-10.01  sec  5.77 GBytes  4.95 Gbits/sec      receiver

After:
jwhited@i5-12400-2:~$ iperf3 -V -c 100.100.100.100
Starting Test: protocol: TCP, 1 streams, 131072 byte blocks
Test Complete. Summary Results:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  12.7 GBytes  10.9 Gbits/sec    0 sender
[  5]   0.00-10.00  sec  12.7 GBytes  10.9 Gbits/sec      receiver

Updates tailscale/corp#22754

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-29 11:37:48 -07:00
Jordan Whited
71acf87830 tstest/integration: add UDP netstack loopback integration test (#13312)
Updates tailscale/corp#22713

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-29 11:17:27 -07:00
Kristoffer Dalby
e93c160a39 nix: update nix and use go 1.23
Updates #12912

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-08-29 17:25:13 +02:00
Nick Khyl
b48c8db69c ipn/ipnlocal: set WantRunning upon an interactive login, but not during a seamless renewal or a profile switch
The LocalBackend's state machine starts in NoState and soon transitions to NeedsLogin if there's no auto-start profile,
with the profileManager starting with a new empty profile. Notably, entering the NeedsLogin state blocks engine updates.
We expect the user to transition out of this state by logging in interactively, and we set WantRunning to true when
controlclient enters the StateAuthenticated state.

While our intention is correct, and completing an interactive login should set WantRunning to true, our assumption
that logging into the current Tailscale profile is the only way to transition out of the NeedsLogin state is not accurate.
Another common transition path includes an explicit profile switch (via LocalBackend.SwitchProfile) or an implicit switch
when a Windows user connects to the backend. This results in a bug where WantRunning is set to true even when it was
previously set to false, and the user expressed no intention of changing it.

A similar issue occurs when switching from (sic) a Tailnet that has seamlessRenewalEnabled, regardless of the current state
of the LocalBackend's state machine, and also results in unexpectedly set WantRunning. While this behavior is generally
undesired, it is also incorrect that it depends on the control knobs of the Tailnet we're switching from rather than
the Tailnet we're switching to. However, this issue needs to be addressed separately.

This PR updates LocalBackend.SetControlClientStatus to only set WantRunning to true in response to an interactive login
as indicated by a non-empty authURL.

Fixes #6668
Fixes #11280
Updates #12756

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-29 09:27:17 -05:00
Brad Fitzpatrick
82c2c5c597 tstest/natlab/vnet: add more tests
This adds tests for DNS requests, and ignoring IPv6 packets on v4-only
networks.

No behavior changes. But some things are pulled out into functions.

And the mkPacket helpers previously just for tests are moved into
non-test code to be used elsewhere to reduce duplication, doing the
checksum stuff automatically.

Updates #13038

Change-Id: I4dd0b73c75b2b9567b4be3f05a2792999d83f6a3
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-28 21:39:29 -07:00
Jordan Whited
d21ebc28af wgengine/netstack: implement netstack loopback (#13301)
When the TS_DEBUG_NETSTACK_LOOPBACK_PORT environment variable is set,
netstack will loop back (dnat to addressFamilyLoopback:loopbackPort)
TCP & UDP flows originally destined to localServicesIP:loopbackPort.
localServicesIP is quad-100 or the IPv6 equivalent.

Updates tailscale/corp#22713

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-28 18:50:13 -07:00
Nick Khyl
80b2b45d60 ipn/ipnlocal: refactor and cleanup profileManager
In preparation for multi-user and unattended mode improvements, we are
refactoring and cleaning up `ipn/ipnlocal.profileManager`. The concept of the
"current user", which is only relevant on Windows, is being deprecated and will
soon be removed to allow more than one Windows user to connect and utilize
`LocalBackend` according to that user's access rights to the device and specific
Tailscale profiles.

We plan to pass the user's identity down to the `profileManager`, where it can
be used to determine the user's access rights to a given `LoginProfile`. While
the new permission model in `ipnauth` requires more work and is currently
blocked pending PR reviews, we are updating the `profileManager` to reduce its
reliance on the concept of a single OS user being connected to the backend at
the same time.

We extract the switching to the default Tailscale profile, which may also
trigger legacy profile migration, from `profileManager.SetCurrentUserID`. This
introduces `profileManager.DefaultUserProfileID`, which returns the default
profile ID for the current user, and `profileManager.SwitchToDefaultProfile`,
which is essentially a shorthand for `pm.SwitchProfile(pm.DefaultUserProfileID())`.
Both methods will eventually be updated to accept the user's identity and
utilize that user's default profile.

We make access checks more explicit by introducing the `profileManager.checkProfileAccess`
method. The current implementation continues to use `profileManager.currentUserID`
and `LoginProfile.LocalUserID` to determine whether access to a given profile
should be granted. This will be updated to utilize the `ipnauth` package and the
new permissions model once it's ready. We also expand access checks to be used
more widely in the `profileManager`, not just when switching or listing
profiles. This includes access checks in methods like `SetPrefs` and, most notably,
`DeleteProfile` and `DeleteAllProfiles`, preventing unprivileged Windows users
from deleting Tailscale profiles owned by other users on the same device,
including profiles owned by local admins.

We extract `profileManager.ProfilePrefs` and `profileManager.SetProfilePrefs`
methods that can be used to get and set preferences of a given `LoginProfile` if
`profileManager.checkProfileAccess` permits access to it.

We also update `profileManager.setUnattendedModeAsConfigured` to always enable
unattended mode on Windows if `Prefs.ForceDaemon` is true in the current
`LoginProfile`, even if `profileManager.currentUserID` is `""`. This facilitates
enabling unattended mode via `tailscale up --unattended` even if
`tailscale-ipn.exe` is not running, such as when a Group Policy or MDM-deployed
script runs at boot time, or when Tailscale is used on a Server Code or otherwise
headless Windows environments. See #12239, #2137, #3186 and
https://github.com/tailscale/tailscale/pull/6255#issuecomment-2016623838 for
details.

Fixes #12239
Updates tailscale/corp#18342
Updates #3186
Updates #2137

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-28 14:42:35 -05:00
Brad Fitzpatrick
73b3c8fc8c tstest/natlab/vnet: add IPv6 all-nodes support
This adds support for sending packets to 33:33:00:00:01 at IPv6
multicast address ff02::1 to send to all nodes.

Nothing in Tailscale depends on this (yet?), but it makes debugging in
VMs behind natlab easier (e.g. you can ping all nodes), and other
things might depend on this in the future.

Mostly I'm trying to flesh out the IPv6 support in natlab now that we
can write vnet tests.

Updates #13038

Change-Id: If590031fcf075690ca35c7b230a38c3e72e621eb
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-28 12:04:19 -07:00
Nick Khyl
961ee321e8 ipn/{ipnauth,ipnlocal,ipnserver,localapi}: start baby step toward moving access checks from the localapi.Handler to the LocalBackend
Currently, we use PermitRead/PermitWrite/PermitCert permission flags to determine which operations are allowed for a LocalAPI client.
These checks are performed when localapi.Handler handles a request. Additionally, certain operations (e.g., changing the serve config)
requires the connected user to be a local admin. This approach is inherently racey and is subject to TOCTOU issues.
We consider it to be more critical on Windows environments, which are inherently multi-user, and therefore we prevent more than one
OS user from connecting and utilizing the LocalBackend at the same time. However, the same type of issues is also applicable to other
platforms when switching between profiles that have different OperatorUser values in ipn.Prefs.

We'd like to allow more than one Windows user to connect, but limit what they can see and do based on their access rights on the device
(e.g., an local admin or not) and to the currently active LoginProfile (e.g., owner/operator or not), while preventing TOCTOU issues on Windows
and other platforms. Therefore, we'd like to pass an actor from the LocalAPI to the LocalBackend to represent the user performing the operation.
The LocalBackend, or the profileManager down the line, will then check the actor's access rights to perform a given operation on the device
and against the current (and/or the target) profile.

This PR does not change the current permission model in any way, but it introduces the concept of an actor and includes some preparatory
work to pass it around. Temporarily, the ipnauth.Actor interface has methods like IsLocalSystem and IsLocalAdmin, which are only relevant
to the current permission model. It also lacks methods that will actually be used in the new model. We'll be adding these gradually in the next
PRs and removing the deprecated methods and the Permit* flags at the end of the transition.

Updates tailscale/corp#18342

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-28 13:49:58 -05:00
Brad Fitzpatrick
8b23ba7d05 tstest/natlab/vnet: add qemu + Virtualization.framework protocol tests
To test how virtual machines connect to the natlab vnet code.

Updates #13038

Change-Id: Ia4fd4b0c1803580ee7d94cc9878d777ad4f24f82
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-27 22:30:20 -07:00
Brad Fitzpatrick
ff1d0aa027 tstest/natlab/vnet: start adding tests
And refactor some of vnet.go for testability.

The only behavioral change (with a new test) is that ethernet
broadcasts no longer get sent back to the sender.

Updates #13038

Change-Id: Ic2e7e7d6d8805b7b7f2b5c52c2c5ba97101cef14
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-27 18:32:48 -07:00
Jordan Whited
31cdbd68b1 net/tstun: fix gvisor inbound GSO packet injection (#13283)
buffs[0] was not sized to hold pkt with GSO, resulting in a panic.

Updates tailscale/corp#22511

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-27 14:59:43 -07:00
Kristoffer Dalby
a2c42d3cd4 usermetric: add initial user-facing metrics
This commit adds a new usermetric package and wires
up metrics across the tailscale client.

Updates tailscale/corp#22075

Co-authored-by: Anton Tolchanov <anton@tailscale.com>
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-08-27 11:21:35 +02:00
Kristoffer Dalby
06c31f4e91 tsweb/varz: remove pprof
Updates tailscale/corp#22075

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-08-27 11:21:35 +02:00
Jordan Whited
bfcb3562e6 wgengine/netstack: re-enable gVisor GSO on Linux (#13269)
This was previously disabled in 8e42510 due to missing GSO-awareness in
tstun, which was resolved in d097096.

Updates tailscale/corp#22511

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-26 20:35:32 -07:00
Jordan Whited
d097096ddc net/tstun,wgengine/netstack: make inbound synthetic packet injection GSO-aware (#13266)
Updates tailscale/corp#22511

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-26 19:26:39 -07:00
Jordan Whited
6d4973e1e0 wgengine/netstack: use types/logger.Logf instead of stdlib log.Printf (#13267)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-26 16:16:06 -07:00
Brad Fitzpatrick
f99f970dc1 tstest/natlab/vnet: rename some things for clarity
The bad naming (which had only been half updated with the IPv6
changes) tripped me up in the earlier change.

Updates #13038

Change-Id: I65ce07c167e8219d35b87e1f4bf61aab4cac31ff
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-26 15:36:30 -07:00
Brad Fitzpatrick
0157000cab tstest/natlab: fix IPv6 tests, remove TODOs
The reason they weren't working was because the cmd/tta agent in the
guest was dialing out to the test and the vnet couldn't map its global
unicast IPv6 address to a node as it was just using a
map[netip.Addr]*node and blindly trusting the *node was
populated. Instead, it was nil, so the agent connection fetching
didn't work for its RoundTripper and the test could never drive the
node. That map worked for IPv4 but for IPv6 we need to use the method
that takes into account the node's IPv6 SLAAC address. Most call sites
had been converted but I'd missed that one.

Also clean up some debug, and prohibit nodes' link-local unicast
addresses from dialing 2000::/3 directly for now. We can allow that to
be configured opt-in later (some sort of IPv6 NAT mode. Whatever it's
called.) That mode was working on accident, but was confusing: Linux
would do source address selection from link local for the first few
seconds and then after SLAAC and DAD, switch to using the global
unicast source address. Be consistent for now and force it to use the
global unicast.

Updates #13038

Change-Id: I85e973aaa38b43c14611943ff45c7c825ee9200a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-26 15:36:30 -07:00
Brad Fitzpatrick
9f7683e2a1 logpolicy: extend the gokrazy/natlab wait-for-network delay for IPv6
Really we need to fix logpolicy + bootstrapDNS to not be so aggressive,
but this is a quick workaround meanwhile.

Without this, tailscaled starts immediately while IPv6 DAD is
happening for a couple seconds and logpolicy freaks out without the
network available and starts spamming stderr about bootstrap DNS
options. But we see that regularly anyway from people whose wifi is
down. So we need to fix the general case. This is not that fix.

Updates #13038

Change-Id: Iba7e536d08e59d34abded1d279f88fdc9c46d94d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-26 15:36:30 -07:00
Brad Fitzpatrick
2636a83d0e cmd/tta: pull out test driver dialing into a type, fix bugs
There were a few places it could get wedged (notably the dial without
a timeout).

And add a knob for verbose debug logs.

And keep two idle connections always.

Updates #13038

Change-Id: I952ad182d7111481d97a83c12aa2ff4bfdc55fe8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-26 15:36:30 -07:00
Brad Fitzpatrick
6dd1af0d1e tstest/natlab: refactor HandleEthernetPacketForRouter a bit
Move all the UDP handling to its own func to remove a bunch of "if
isUDP" checks in a bunch of blocks.

Updates #13038

Change-Id: If71d71b49e57651d15bd307a2233c43751cc8639
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-26 15:36:30 -07:00
Brad Fitzpatrick
3a8cfbc381 tstest/natlab: be more paranoid about IP versions from gvisor
I didn't actually see this, but added this while debugging something
and figured it'd be good to keep.

Updates #13038

Change-Id: I67934c8a329e0233f79c3b08516fd6bad6bfe22a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-26 15:36:30 -07:00
Brad Fitzpatrick
e0bdd5d058 tstest/natlab: simplify a defer
Updates #13038

Change-Id: I4d38701491523c64c81767b0838010609e683a9f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-26 15:36:30 -07:00
Will Norris
cccacff564 types/opt: add BoolFlag for setting Bool value as a flag
Updates tailscale/corp#22578

Signed-off-by: Will Norris <will@tailscale.com>
2024-08-26 11:32:35 -07:00
James Tucker
8af50fa97c ipn/ipnlocal: update routes on link change with ExitNodeAllowLANAccess
On a major link change the LAN routes may change, so on linkChange where
ChangeDelta.Major, we need to call authReconfig to ensure that new
routes are observed and applied.

Updates tailscale/corp#22574

Signed-off-by: James Tucker <james@tailscale.com>
2024-08-26 11:27:38 -07:00
Brad Fitzpatrick
b78df4d48a tstest/natlab/vnet: add start of IPv6 support
Updates #13038

Change-Id: Ic3d095f167daf6c7129463e881b18f2e0d5693f5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-24 18:02:38 -07:00
Maisem Ali
31b5239a2f tstest/natlab/vnet: flush and sync pcap file after every packet
So that we can view the pcap as we debug interactively.

Updates #13038

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-24 11:44:50 -07:00
Jordan Whited
978306565d tstest/integration: change log.Fatal() to t.Fatal() (#13253)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-23 16:58:31 -07:00
Jordan Whited
367bfa607c tstest/integration: exercise TCP DNS queries against quad-100 (#13231)
Updates tailscale/corp#22511

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-23 16:40:18 -07:00
Jordan Whited
641693d61c ipn/ipnlocal: install IPv6 service addr route (#13252)
This is the equivalent of quad-100, but for IPv6. This is technically
already contained in the Tailscale IPv6 ULA prefix, but that is only
installed when remote peers are visible via control with contained
addrs. The service addr should always be reachable.

Updates #1152

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-23 16:22:56 -07:00
Brad Fitzpatrick
475ab1fb67 cmd/vnet: omit log spam when backend status hasn't changed
Updates #13038

Change-Id: I9cc67cf18ba44ff66ba03cda486d5e111e395ce7
2024-08-23 14:24:01 -07:00
Brad Fitzpatrick
e5fd36ad78 tstest/natlab: respect NATTable interface's invalid-means-drop everywhere
And sprinkle some more docs around.

Updates #13038

Change-Id: Ia2dcf567b68170481cc2094d64b085c6b94a778a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-23 14:21:59 -07:00
Nick Khyl
03acab2639 cmd/cloner, cmd/viewer, util/codegen: add support for aliases of cloneable types
We have several checked type assertions to *types.Named in both cmd/cloner and cmd/viewer.
As Go 1.23 updates the go/types package to produce Alias type nodes for type aliases,
these type assertions no longer work as expected unless the new behavior is disabled
with gotypesalias=0.

In this PR, we add codegen.NamedTypeOf(t types.Type), which functions like t.(*types.Named)
but also unrolls type aliases. We then use it in place of type assertions in the cmd/cloner and
cmd/viewer packages where appropriate.

We also update type switches to include *types.Alias alongside *types.Named in relevant cases,
remove *types.Struct cases when switching on types.Type.Underlying and update the tests
with more cases where type aliases can be used.

Updates #13224
Updates #12912

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-23 15:43:40 -05:00
Nick Khyl
a9dc6e07ad util/codegen, cmd/cloner, cmd/viewer: update codegen.LookupMethod to support alias type nodes
Go 1.23 updates the go/types package to produce Alias type nodes for type aliases, unless disabled with gotypesalias=0.
This new default behavior breaks codegen.LookupMethod, which uses checked type assertions to types.Named and
types.Interface, as only named types and interfaces have methods.

In this PR, we update codegen.LookupMethod to perform method lookup on the right-hand side of the alias declaration
and clearly switch on the supported type nodes types. We also improve support for various edge cases, such as when an alias
is used as a type parameter constraint, and add tests for the LookupMethod function.

Additionally, we update cmd/viewer/tests to include types with aliases used in type fields and generic type constraints.

Updates #13224
Updates #12912

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-23 15:26:05 -05:00
Brad Fitzpatrick
aa42ae9058 tstest/natlab: make a new virtualIP type in prep for IPv6 support
All the magic service names with virtual IPs will need IPv6 variants.

Pull this out in prep.

Updates #13038

Change-Id: I53b5eebd0679f9fa43dc0674805049258c83a0de
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-23 13:16:33 -07:00
Brad Fitzpatrick
5a99940dfa tstest/natlab/vnet: explicitly ignore PCP and SSDP UDP queries
So we don't log about them when verbose logging is enabled.

Updates #13038

Change-Id: I925bc3a23e6c93d60dd4fb4bf6a4fdc5a326de95
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-23 12:10:19 -07:00
Brad Fitzpatrick
3b70968c25 cmd/vnet: add --blend and --pcap flags
Updates #13038

Change-Id: Id16ea9eb94447a3d9651215f04b2525daf10b3eb
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-23 12:10:19 -07:00
Brad Fitzpatrick
3904e4d175 cmd/tta, tstest/natlab/vnet: remove unneeded port 124 log hack, add log buffer
The natlab Test Agent (tta) still had its old log streaming hack in
place where it dialed out to anything on TCP port 124 and those logs
were streamed to the host running the tests. But we'd since added gokrazy
syslog streaming support, which made that redundant.

So remove all the port 124 stuff. And then make sure we log to stderr
so gokrazy logs it to syslog.

Also, keep the first 1MB of logs in memory in tta too, exported via
localhost:8034/logs for interactive debugging. That was very useful
during debugging when I added IPv6 support. (which is coming in future
PRs)

Updates #13038

Change-Id: Ieed904a704410b9031d5fd5f014a73412348fa7f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-23 12:10:19 -07:00
Flakes Updater
d862898fd3 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-08-23 10:30:07 -07:00
Brad Fitzpatrick
b091264c0a cmd/systray: set ipn.NotifyNoPrivateKeys, permit non-operator use
Otherwise you get "Access denied: watch IPN bus access denied, must
set ipn.NotifyNoPrivateKeys when not running as admin/root or
operator".

This lets a non-operator at least start the app and see the status, even
if they can't change everything. (the web UI is unaffected by operator)

A future change can add a LocalAPI call to check permissions and guide
people through adding a user as an operator (perhaps the web client
can do that?)

Updates #1708

Change-Id: I699e035a251b4ebe14385102d5e7a2993424c4b7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-23 10:15:49 -07:00
Will Norris
3c66ee3f57 cmd/systray: add a basic linux systray app
This adds a systray app for linux, similar to the apps for macOS and
windows. There are already a number of community-developed systray apps,
but most of them are either long abandoned, are built for a specific
desktop environment, or simply wrap the tailscale CLI.

This uses fyne.io/systray (a fork of github.com/getlantern/systray)
which uses newer D-Bus specifications to render the tray icon and menu.
This results in a pretty broad support for modern desktop environments.

This initial commit lacks a number of features like profile switching,
device listing, and exit node selection. This is really focused on the
application structure, the interaction with LocalAPI, and some system
integration pieces like the app icon, notifications, and the clipboard.

Updates #1708

Signed-off-by: Will Norris <will@tailscale.com>
2024-08-23 00:35:25 -07:00
Flakes Updater
6280c44be1 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-08-22 15:42:08 -07:00
Jonathan Nobels
1191eb0e3d tstest/natlab: add unix address to writer for dgram mode
updates tailcale/corp#22371

For dgram mode, we need to store the write addresses of
the client socket(s) alongside the writer functions and
the write operation needs to use WriteToUnix.

Unix also has multiple clients writing to the same socket,
so the serve method is modified to handle packets from
multiple mac addresses.

Cleans up a bit of cruft from the initial tailmac tooling
commit.

Now all the macOS packets are belong to us.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2024-08-22 15:37:37 -07:00
Percy Wegmann
743d296073 update to github.com/tailscale/netlink library that doesn't require vishvananda/netlink
Fixes #12298

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-08-22 17:35:37 -05:00
Percy Wegmann
d00d6d6dc2 go.mod: update to github.com/tailscale/netlink library that doesn't require vishvananda/netlink
After the upstream PR is merged, we can point directly at github.com/vishvananda/netlink
and retire github.com/tailscale/netlink.

See https://github.com/vishvananda/netlink/pull/1006

Updates #12298

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-08-22 17:35:37 -05:00
Brad Fitzpatrick
e54c81d1d0 types/views: add Slice.All iterator
And convert a few callers as an example, but nowhere near all.

Updates #12912

Change-Id: I5eaa12a29a6cd03b58d6f1072bd27bc0467852f2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-22 14:55:33 -07:00
Flakes Updater
aedfb82876 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-08-22 12:48:46 -07:00
Ilarion Kovalchuk
0cb7eb9b75 net/dns: updated gonotify dependency to v2 that supports closable context
Signed-off-by: Ilarion Kovalchuk <illarion.kovalchuk@gmail.com>
2024-08-22 12:36:26 -07:00
Brad Fitzpatrick
696711cc17 all: switch to and require Go 1.23
Updates #12912

Change-Id: Ib4ae26eb5fb68ad2216cab4913811b94f7eed5b6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-22 12:31:08 -07:00
Brad Fitzpatrick
0ff474ff37 all: fix new lint warnings from bumping staticcheck
In prep for updating to new staticcheck required for Go 1.23.

Updates #12912

Change-Id: If77892a023b79c6fa798f936fc80428fd4ce0673
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-22 12:31:08 -07:00
Percy Wegmann
4637ac732e ipn/ipnlocal: remember last notified taildrive shares and only notify if they've changed
Fixes #13195

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-08-22 08:51:07 -05:00
Brad Fitzpatrick
690d3bfafe cmd/tailscale/cli: add debug command to do DNS lookups portably
To avoid dig vs nslookup vs $X availability issues between
OSes/distros. And to be in Go, to match the resolver we use.

Updates #13038

Change-Id: Ib7e5c351ed36b5470a42cbc230b8f27eed9a1bf8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-21 20:35:33 -07:00
239 changed files with 18102 additions and 5767 deletions

View File

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

View File

@@ -35,7 +35,7 @@ jobs:
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 #v7.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: Flakes Updater <noreply+flakes-updater@tailscale.com>

View File

@@ -34,7 +34,7 @@ jobs:
- name: Send pull request
id: pull-request
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 #v7.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: OSS Updater <noreply+oss-updater@tailscale.com>

View File

@@ -27,7 +27,7 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.22-alpine AS build-env
FROM golang:1.23-alpine AS build-env
WORKDIR /go/src/tailscale

View File

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

View File

@@ -1 +1 @@
1.73.0
1.75.0

102
api.md
View File

@@ -1,104 +1,2 @@
> [!IMPORTANT]
> The Tailscale API documentation has moved to https://tailscale.com/api
# Tailscale API
The Tailscale API documentation is located in **[tailscale/publicapi](./publicapi/readme.md#tailscale-api)**.
# APIs
**[Overview](./publicapi/readme.md)**
**[Device](./publicapi/device.md#device)**
<a href="device-delete"></a>
<a href="expire-device-key"></a>
<a href="device-routes-get">
<a href="device-routes-post"></a>
<a href="#device-authorized-post"></a>
<a href="device-tags-post"></a>
<a href="device-key-post"></a>
<a href="tailnet-acl-get"></a>
- Get a device: [`GET /api/v2/device/{deviceid}`](./publicapi/device.md#get-device)
- Delete a device: [`DELETE /api/v2/device/{deviceID}`](./publicapi/device.md#delete-device)
- Expire device key: [`POST /api/v2/device/{deviceID}/expire`](./publicapi/device.md#expire-device-key)
- [**Routes**](./publicapi/device.md#routes)
- Get device routes: [`GET /api/v2/device/{deviceID}/routes`](./publicapi/device.md#get-device-routes)
- Set device routes: [`POST /api/v2/device/{deviceID}/routes`](./publicapi/device.md#set-device-routes)
- [**Authorize**](./publicapi/device.md#authorize)
- Authorize a device: [`POST /api/v2/device/{deviceID}/authorized`](./publicapi/device.md#authorize-device)
- [**Tags**](./publicapi/device.md#tags)
- Update tags: [`POST /api/v2/device/{deviceID}/tags`](./publicapi/device.md#update-device-tags)
- [**Keys**](./publicapi/device.md#keys)
- Update device key: [`POST /api/v2/device/{deviceID}/key`](./publicapi/device.md#update-device-key)
- [**IP Addresses**](./publicapi/device.md#ip-addresses)
- Set device IPv4 address: [`POST /api/v2/device/{deviceID}/ip`](./publicapi/device.md#set-device-ipv4-address)
- [**Device posture attributes**](./publicapi/device.md#device-posture-attributes)
- Get device posture attributes: [`GET /api/v2/device/{deviceID}/attributes`](./publicapi/device.md#get-device-posture-attributes)
- Set custom device posture attributes: [`POST /api/v2/device/{deviceID}/attributes/{attributeKey}`](./publicapi/device.md#set-device-posture-attributes)
- Delete custom device posture attributes: [`DELETE /api/v2/device/{deviceID}/attributes/{attributeKey}`](./publicapi/device.md#delete-custom-device-posture-attributes)
- [**Device invites**](./publicapi/device.md#invites-to-a-device)
- List device invites: [`GET /api/v2/device/{deviceID}/device-invites`](./publicapi/device.md#list-device-invites)
- Create device invites: [`POST /api/v2/device/{deviceID}/device-invites`](./publicapi/device.md#create-device-invites)
**[Tailnet](./publicapi/tailnet.md#tailnet)**
<a href="tailnet-acl-post"></a>
<a href="tailnet-acl-preview-post"></a>
<a href="tailnet-acl-validate-post"></a>
<a href="tailnet-devices"></a>
<a href="tailnet-keys-get"></a>
<a href="tailnet-keys-post"></a>
<a href="tailnet-keys-key-get"></a>
<a href="tailnet-keys-key-delete"></a>
<a href="tailnet-dns"></a>
<a href="tailnet-dns-nameservers-get"></a>
<a href="tailnet-dns-nameservers-post"></a>
<a href="tailnet-dns-preferences-get"></a>
<a href="tailnet-dns-preferences-post"></a>
<a href="tailnet-dns-searchpaths-get"></a>
<a href="tailnet-dns-searchpaths-post"></a>
- [**Policy File**](./publicapi/tailnet.md#policy-file)
- Get policy file: [`GET /api/v2/tailnet/{tailnet}/acl`](./publicapi/tailnet.md#get-policy-file)
- Update policy file: [`POST /api/v2/tailnet/{tailnet}/acl`](./publicapi/tailnet.md#update-policy-file)
- Preview rule matches: [`POST /api/v2/tailnet/{tailnet}/acl/preview`](./publicapi/tailnet.md#preview-policy-file-rule-matches)
- Validate and test policy file: [`POST /api/v2/tailnet/{tailnet}/acl/validate`](./publicapi/tailnet.md#validate-and-test-policy-file)
- [**Devices**](./publicapi/tailnet.md#devices)
- List tailnet devices: [`GET /api/v2/tailnet/{tailnet}/devices`](./publicapi/tailnet.md#list-tailnet-devices)
- [**Keys**](./publicapi/tailnet.md#tailnet-keys)
- List tailnet keys: [`GET /api/v2/tailnet/{tailnet}/keys`](./publicapi/tailnet.md#list-tailnet-keys)
- Create an auth key: [`POST /api/v2/tailnet/{tailnet}/keys`](./publicapi/tailnet.md#create-auth-key)
- Get a key: [`GET /api/v2/tailnet/{tailnet}/keys/{keyid}`](./publicapi/tailnet.md#get-key)
- Delete a key: [`DELETE /api/v2/tailnet/{tailnet}/keys/{keyid}`](./publicapi/tailnet.md#delete-key)
- [**DNS**](./publicapi/tailnet.md#dns)
- [**Nameservers**](./publicapi/tailnet.md#nameservers)
- Get nameservers: [`GET /api/v2/tailnet/{tailnet}/dns/nameservers`](./publicapi/tailnet.md#get-nameservers)
- Set nameservers: [`POST /api/v2/tailnet/{tailnet}/dns/nameservers`](./publicapi/tailnet.md#set-nameservers)
- [**Preferences**](./publicapi/tailnet.md#preferences)
- Get DNS preferences: [`GET /api/v2/tailnet/{tailnet}/dns/preferences`](./publicapi/tailnet.md#get-dns-preferences)
- Set DNS preferences: [`POST /api/v2/tailnet/{tailnet}/dns/preferences`](./publicapi/tailnet.md#set-dns-preferences)
- [**Search Paths**](./publicapi/tailnet.md#search-paths)
- Get search paths: [`GET /api/v2/tailnet/{tailnet}/dns/searchpaths`](./publicapi/tailnet.md#get-search-paths)
- Set search paths: [`POST /api/v2/tailnet/{tailnet}/dns/searchpaths`](./publicapi/tailnet.md#set-search-paths)
- [**Split DNS**](./publicapi/tailnet.md#split-dns)
- Get split DNS: [`GET /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#get-split-dns)
- Update split DNS: [`PATCH /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#update-split-dns)
- Set split DNS: [`PUT /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#set-split-dns)
- [**User invites**](./publicapi/tailnet.md#tailnet-user-invites)
- List user invites: [`GET /api/v2/tailnet/{tailnet}/user-invites`](./publicapi/tailnet.md#list-user-invites)
- Create user invites: [`POST /api/v2/tailnet/{tailnet}/user-invites`](./publicapi/tailnet.md#create-user-invites)
**[User invites](./publicapi/userinvites.md#user-invites)**
- Get user invite: [`GET /api/v2/user-invites/{userInviteId}`](./publicapi/userinvites.md#get-user-invite)
- Delete user invite: [`DELETE /api/v2/user-invites/{userInviteId}`](./publicapi/userinvites.md#delete-user-invite)
- Resend user invite (by email): [`POST /api/v2/user-invites/{userInviteId}/resend`](#resend-user-invite)
**[Device invites](./publicapi/deviceinvites.md#device-invites)**
- Get device invite: [`GET /api/v2/device-invites/{deviceInviteId}`](./publicapi/deviceinvites.md#get-device-invite)
- Delete device invite: [`DELETE /api/v2/device-invites/{deviceInviteId}`](./publicapi/deviceinvites.md#delete-device-invite)
- Resend device invite (by email): [`POST /api/v2/device-invites/{deviceInviteId}/resend`](./publicapi/deviceinvites.md#resend-device-invite)
- Accept device invite [`POST /api/v2/device-invites/-/accept`](#accept-device-invite)

View File

@@ -19,6 +19,7 @@ import (
// Only one of Src/Dst or Users/Ports may be specified.
type ACLRow struct {
Action string `json:"action,omitempty"` // valid values: "accept"
Proto string `json:"proto,omitempty"` // protocol
Users []string `json:"users,omitempty"` // old name for src
Ports []string `json:"ports,omitempty"` // old name for dst
Src []string `json:"src,omitempty"`
@@ -31,6 +32,7 @@ type ACLRow struct {
type ACLTest struct {
Src string `json:"src,omitempty"` // source
User string `json:"user,omitempty"` // old name for source
Proto string `json:"proto,omitempty"` // protocol
Accept []string `json:"accept,omitempty"` // expected destination ip:port that user can access
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access

View File

@@ -4,7 +4,10 @@
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
package apitype
import "tailscale.com/tailcfg"
import (
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
)
// LocalAPIHost is the Host header value used by the LocalAPI.
const LocalAPIHost = "local-tailscaled.sock"
@@ -57,3 +60,19 @@ type ExitNodeSuggestionResponse struct {
Name string
Location tailcfg.LocationView `json:",omitempty"`
}
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
// into the CLI.
type DNSOSConfig struct {
Nameservers []string
SearchDomains []string
MatchDomains []string
}
// DNSQueryResponse is the response to a DNS query request sent via LocalAPI.
type DNSQueryResponse struct {
// Bytes is the raw DNS response bytes.
Bytes []byte
// Resolvers is the list of resolvers that the forwarder deemed able to resolve the query.
Resolvers []*dnstype.Resolver
}

View File

@@ -37,6 +37,7 @@ import (
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
)
@@ -353,6 +354,12 @@ func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/metrics")
}
// UserMetrics returns the user metrics in
// the Prometheus text exposition format.
func (lc *LocalClient) UserMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/usermetrics")
}
// 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.
@@ -807,6 +814,35 @@ func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn
return decodeJSON[*ipn.Prefs](body)
}
// GetDNSOSConfig returns the system DNS configuration for the current device.
// That is, it returns the DNS configuration that the system would use if Tailscale weren't being used.
func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
body, err := lc.get200(ctx, "/localapi/v0/dns-osconfig")
if err != nil {
return nil, err
}
var osCfg apitype.DNSOSConfig
if err := json.Unmarshal(body, &osCfg); err != nil {
return nil, fmt.Errorf("invalid dns.OSConfig: %w", err)
}
return &osCfg, nil
}
// QueryDNS executes a DNS query for a name (`google.com.`) and query type (`CNAME`).
// It returns the raw DNS response bytes and the resolvers that were used to answer the query
// (often just one, but can be more if we raced multiple resolvers).
func (lc *LocalClient) QueryDNS(ctx context.Context, name string, queryType string) (bytes []byte, resolvers []*dnstype.Resolver, err error) {
body, err := lc.get200(ctx, fmt.Sprintf("/localapi/v0/dns-query?name=%s&type=%s", url.QueryEscape(name), queryType))
if err != nil {
return nil, nil, err
}
var res apitype.DNSQueryResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, nil, fmt.Errorf("invalid query response: %w", err)
}
return res.Bytes, res.Resolvers, nil
}
// StartLoginInteractive starts an interactive login.
func (lc *LocalClient) StartLoginInteractive(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
@@ -1434,6 +1470,13 @@ func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode strin
return decodeJSON[*ipnstate.DebugDERPRegionReport](body)
}
// DebugEnvknob sets a envknob for debugging purposes.
func (lc *LocalClient) DebugEnvknob(ctx context.Context, key, value string) error {
v := url.Values{"key": {key}, "value": {value}}
_, err := lc.send(ctx, "POST", "/localapi/v0/debug-envknob?"+v.Encode(), 200, nil)
return err
}
// DebugPacketFilterRules returns the packet filter rules for the current device.
func (lc *LocalClient) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil)

View File

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

View File

@@ -283,6 +283,12 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
}
}
if r.URL.Path == "/metrics" {
r.URL.Path = "/api/local/v0/usermetrics"
s.proxyRequestToLocalAPI(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/api/") {
switch {
case r.URL.Path == "/api/auth" && r.Method == httpm.GET:

View File

@@ -5382,9 +5382,9 @@ wrappy@1:
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^8.14.2:
version "8.14.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
xml-name-validator@^5.0.0:
version "5.0.0"

View File

@@ -47,7 +47,7 @@ func main() {
it := codegen.NewImportTracker(pkg.Types)
buf := new(bytes.Buffer)
for _, typeName := range typeNames {
typ, ok := namedTypes[typeName]
typ, ok := namedTypes[typeName].(*types.Named)
if !ok {
log.Fatalf("could not find type %s", typeName)
}
@@ -115,7 +115,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
continue
}
if named, _ := ft.(*types.Named); named != nil {
if named, _ := codegen.NamedTypeOf(ft); named != nil {
if codegen.IsViewType(ft) {
writef("dst.%s = src.%s", fname, fname)
continue
@@ -161,7 +161,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
case *types.Pointer:
base := ft.Elem()
hasPtrs := codegen.ContainsPointers(base)
if named, _ := base.(*types.Named); named != nil && hasPtrs {
if named, _ := codegen.NamedTypeOf(base); named != nil && hasPtrs {
writef("dst.%s = src.%s.Clone()", fname, fname)
continue
}

View File

@@ -0,0 +1,262 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"fmt"
"log"
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"tailscale.com/util/linuxfw"
)
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error {
var (
v4Forwarding, v6Forwarding bool
)
if clusterProxyTargetIP != "" {
proxyIP, err := netip.ParseAddr(clusterProxyTargetIP)
if err != nil {
return fmt.Errorf("invalid cluster destination IP: %v", err)
}
if proxyIP.Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
if tailnetTargetIP != "" {
proxyIP, err := netip.ParseAddr(tailnetTargetIP)
if err != nil {
return fmt.Errorf("invalid tailnet destination IP: %v", err)
}
if proxyIP.Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
// Currently we only proxy traffic to the IPv4 address of the tailnet
// target.
if tailnetTargetFQDN != "" {
v4Forwarding = true
}
if routes != nil && *routes != "" {
for _, route := range strings.Split(*routes, ",") {
cidr, err := netip.ParsePrefix(route)
if err != nil {
return fmt.Errorf("invalid subnet route: %v", err)
}
if cidr.Addr().Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
}
return enableIPForwarding(v4Forwarding, v6Forwarding, root)
}
func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
var paths []string
if v4Forwarding {
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
}
if v6Forwarding {
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding"))
}
// In some common configurations (e.g. default docker,
// kubernetes), the container environment denies write access to
// most sysctls, including IP forwarding controls. Check the
// sysctl values before trying to change them, so that we
// gracefully do nothing if the container's already been set up
// properly by e.g. a k8s initContainer.
for _, path := range paths {
bs, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %q: %w", path, err)
}
if v := strings.TrimSpace(string(bs)); v != "1" {
if err := os.WriteFile(path, []byte("1"), 0644); err != nil {
return fmt.Errorf("enabling %q: %w", path, err)
}
}
}
return nil
}
func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
if err := nfr.AddSNATRuleForDst(local, dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
return nil
}
// installTSForwardingRuleForDestination accepts a destination address and a
// list of node's tailnet addresses, sets up rules to forward traffic for
// destination to the tailnet IP matching the destination IP family.
// Destination can be Pod IP of this node.
func installTSForwardingRuleForDestination(_ context.Context, dstFilter string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstFilter)
if err != nil {
return err
}
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstFilter, tsIPs)
}
if err := nfr.AddDNATRule(dst, local); err != nil {
return fmt.Errorf("installing rule for forwarding traffic to tailnet IP: %w", err)
}
return nil
}
func installIngressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
proxyHasIPv4Address := false
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() {
proxyHasIPv4Address = true
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if proxyHasIPv4Address && dst.Is6() {
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
if err := nfr.AddDNATRule(local, dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
return nil
}
func installIngressForwardingRuleForDNSTarget(_ context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
var (
tsv4 netip.Addr
tsv6 netip.Addr
v4Backends []netip.Addr
v6Backends []netip.Addr
)
for _, pfx := range tsIPs {
if pfx.IsSingleIP() && pfx.Addr().Is4() {
tsv4 = pfx.Addr()
continue
}
if pfx.IsSingleIP() && pfx.Addr().Is6() {
tsv6 = pfx.Addr()
continue
}
}
// TODO: log if more than one backend address is found and firewall is
// in nftables mode that only the first IP will be used.
for _, ip := range backendAddrs {
if ip.To4() != nil {
v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4())))
}
if ip.To16() != nil {
v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16())))
}
}
// Enable IP forwarding here as opposed to at the start of containerboot
// as the IPv4/IPv6 requirements might have changed.
// For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is
// enabled by an init container, so in practice enabling forwarding here
// is only needed if this proxy has been configured by manually setting
// TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance.
if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil {
log.Printf("[unexpected] failed to ensure IP forwarding: %v", err)
}
updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error {
if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil {
return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err)
}
// The backend might advertize MSS higher than that of the
// tailscale interfaces. Clamp MSS of packets going out via
// tailscale0 interface to its MTU to prevent broken connections
// in environments where path MTU discovery is not working.
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err)
}
return nil
}
if len(v4Backends) != 0 {
if !tsv4.IsValid() {
log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs)
} else if err := updateFirewall(tsv4, v4Backends); err != nil {
return fmt.Errorf("Installing IPv4 firewall rules: %w", err)
}
}
if len(v6Backends) != 0 && !tsv6.IsValid() {
if !tsv6.IsValid() {
log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs)
} else if !nfr.HasIPV6NAT() {
log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs)
} else if err := updateFirewall(tsv6, v6Backends); err != nil {
return fmt.Errorf("Installing IPv6 firewall rules: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"log"
"net"
"net/http"
"sync"
)
// healthz is a simple health check server, if enabled it returns 200 OK if
// this tailscale node currently has at least one tailnet IP address else
// returns 503.
type healthz struct {
sync.Mutex
hasAddrs bool
}
func (h *healthz) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.Lock()
defer h.Unlock()
if h.hasAddrs {
w.Write([]byte("ok"))
} else {
http.Error(w, "node currently has no tailscale IPs", http.StatusInternalServerError)
}
}
// runHealthz runs a simple HTTP health endpoint on /healthz, listening on the
// provided address. A containerized tailscale instance is considered healthy if
// it has at least one tailnet IP address.
func runHealthz(addr string, h *healthz) {
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("error listening on the provided health endpoint address %q: %v", addr, err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", h)
log.Printf("Running healthcheck endpoint at %s/healthz", addr)
hs := &http.Server{Handler: mux}
go func() {
if err := hs.Serve(lis); err != nil {
log.Fatalf("failed running health endpoint: %v", err)
}
}()
}

View File

@@ -8,21 +8,21 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/netip"
"os"
"tailscale.com/kube"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
"tailscale.com/tailcfg"
)
// storeDeviceID writes deviceID to 'device_id' data field of the named
// Kubernetes Secret.
func storeDeviceID(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID) error {
s := &kube.Secret{
s := &kubeapi.Secret{
Data: map[string][]byte{
"device_id": []byte(deviceID),
},
@@ -42,7 +42,7 @@ func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, a
return err
}
s := &kube.Secret{
s := &kubeapi.Secret{
Data: map[string][]byte{
"device_fqdn": []byte(fqdn),
"device_ips": deviceIPs,
@@ -55,14 +55,14 @@ func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, a
// secret. No-op if there is no authkey in the secret.
func deleteAuthKey(ctx context.Context, secretName string) error {
// m is a JSON Patch data structure, see https://jsonpatch.com/ or RFC 6902.
m := []kube.JSONPatch{
m := []kubeclient.JSONPatch{
{
Op: "remove",
Path: "/data/authkey",
},
}
if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil {
if s, ok := err.(*kube.Status); ok && s.Code == http.StatusUnprocessableEntity {
if s, ok := err.(*kubeapi.Status); ok && s.Code == http.StatusUnprocessableEntity {
// This is kubernetes-ese for "the field you asked to
// delete already doesn't exist", aka no-op.
return nil
@@ -72,66 +72,16 @@ func deleteAuthKey(ctx context.Context, secretName string) error {
return nil
}
var kc kube.Client
// setupKube is responsible for doing any necessary configuration and checks to
// ensure that tailscale state storage and authentication mechanism will work on
// Kubernetes.
func (cfg *settings) setupKube(ctx context.Context) error {
if cfg.KubeSecret == "" {
return nil
}
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
if err != nil {
return fmt.Errorf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
}
cfg.KubernetesCanPatch = canPatch
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
if err != nil && kube.IsNotFoundErr(err) && !canCreate {
return fmt.Errorf("Tailscale state Secret %s does not exist and we don't have permissions to create it. "+
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
} else if err != nil && !kube.IsNotFoundErr(err) {
return fmt.Errorf("Getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
}
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
if s == nil {
log.Print("TS_AUTHKEY not provided and kube secret does not exist, login will be interactive if needed.")
return nil
}
keyBytes, _ := s.Data["authkey"]
key := string(keyBytes)
if key != "" {
// This behavior of pulling authkeys from kube secrets was added
// at the same time as the patch permission, so we can enforce
// that we must be able to patch out the authkey after
// authenticating if you want to use this feature. This avoids
// us having to deal with the case where we might leave behind
// an unnecessary reusable authkey in a secret, like a rake in
// the grass.
if !cfg.KubernetesCanPatch {
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
}
cfg.AuthKey = key
} else {
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
}
}
return nil
}
var kc kubeclient.Client
func initKubeClient(root string) {
if root != "/" {
// If we are running in a test, we need to set the root path to the fake
// service account directory.
kube.SetRootPathForTesting(root)
kubeclient.SetRootPathForTesting(root)
}
var err error
kc, err = kube.New()
kc, err = kubeclient.New()
if err != nil {
log.Fatalf("Error creating kube client: %v", err)
}

View File

@@ -11,7 +11,8 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/kube"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
)
func TestSetupKube(t *testing.T) {
@@ -20,7 +21,7 @@ func TestSetupKube(t *testing.T) {
cfg *settings
wantErr bool
wantCfg *settings
kc kube.Client
kc kubeclient.Client
}{
{
name: "TS_AUTHKEY set, state Secret exists",
@@ -28,11 +29,11 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, nil
},
},
@@ -47,12 +48,12 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, true, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, &kubeapi.Status{Code: 404}
},
},
wantCfg: &settings{
@@ -66,12 +67,12 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, &kubeapi.Status{Code: 404}
},
},
wantCfg: &settings{
@@ -86,12 +87,12 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 403}
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, &kubeapi.Status{Code: 403}
},
},
wantCfg: &settings{
@@ -110,7 +111,7 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, errors.New("broken")
},
@@ -126,12 +127,12 @@ func TestSetupKube(t *testing.T) {
wantCfg: &settings{
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, true, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, &kubeapi.Status{Code: 404}
},
},
},
@@ -144,12 +145,12 @@ func TestSetupKube(t *testing.T) {
wantCfg: &settings{
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{}, nil
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return &kubeapi.Secret{}, nil
},
},
},
@@ -158,12 +159,12 @@ func TestSetupKube(t *testing.T) {
cfg: &settings{
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
},
},
wantCfg: &settings{
@@ -176,12 +177,12 @@ func TestSetupKube(t *testing.T) {
cfg: &settings{
KubeSecret: "foo",
},
kc: &kube.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return true, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
},
},
wantCfg: &settings{

View File

@@ -92,36 +92,28 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"math"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
"reflect"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
"golang.org/x/sys/unix"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/conffile"
kubeutils "tailscale.com/k8s-operator"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
@@ -650,221 +642,6 @@ runLoop:
wg.Wait()
}
// watchServeConfigChanges watches path for changes, and when it sees one, reads
// the serve config from it, replacing ${TS_CERT_DOMAIN} with certDomain, and
// applies it to lc. It exits when ctx is canceled. cdChanged is a channel that
// is written to when the certDomain changes, causing the serve config to be
// re-read and applied.
func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *tailscale.LocalClient) {
if certDomainAtomic == nil {
panic("cd must not be nil")
}
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
if w, err := fsnotify.NewWatcher(); err != nil {
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
tickChan = ticker.C
} else {
defer w.Close()
if err := w.Add(filepath.Dir(path)); err != nil {
log.Fatalf("failed to add fsnotify watch: %v", err)
}
eventChan = w.Events
}
var certDomain string
var prevServeConfig *ipn.ServeConfig
for {
select {
case <-ctx.Done():
return
case <-cdChanged:
certDomain = *certDomainAtomic.Load()
case <-tickChan:
case <-eventChan:
// We can't do any reasonable filtering on the event because of how
// k8s handles these mounts. So just re-read the file and apply it
// if it's changed.
}
if certDomain == "" {
continue
}
sc, err := readServeConfig(path, certDomain)
if err != nil {
log.Fatalf("failed to read serve config: %v", err)
}
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
continue
}
log.Printf("Applying serve config")
if err := lc.SetServeConfig(ctx, sc); err != nil {
log.Fatalf("failed to set serve config: %v", err)
}
prevServeConfig = sc
}
}
// readServeConfig reads the ipn.ServeConfig from path, replacing
// ${TS_CERT_DOMAIN} with certDomain.
func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
if path == "" {
return nil, nil
}
j, err := os.ReadFile(path)
if err != nil {
return nil, err
}
j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain))
var sc ipn.ServeConfig
if err := json.Unmarshal(j, &sc); err != nil {
return nil, err
}
return &sc, nil
}
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, *os.Process, error) {
args := tailscaledArgs(cfg)
// tailscaled runs without context, since it needs to persist
// beyond the startup timeout in ctx.
cmd := exec.Command("tailscaled", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
log.Printf("Starting tailscaled")
if err := cmd.Start(); err != nil {
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
}
// Wait for the socket file to appear, otherwise API ops will racily fail.
log.Printf("Waiting for tailscaled socket")
for {
if ctx.Err() != nil {
log.Fatalf("Timed out waiting for tailscaled socket")
}
_, err := os.Stat(cfg.Socket)
if errors.Is(err, fs.ErrNotExist) {
time.Sleep(100 * time.Millisecond)
continue
} else if err != nil {
log.Fatalf("Waiting for tailscaled socket: %v", err)
}
break
}
tsClient := &tailscale.LocalClient{
Socket: cfg.Socket,
UseSocketOnly: true,
}
return tsClient, cmd.Process, nil
}
// tailscaledArgs uses cfg to construct the argv for tailscaled.
func tailscaledArgs(cfg *settings) []string {
args := []string{"--socket=" + cfg.Socket}
switch {
case cfg.InKubernetes && cfg.KubeSecret != "":
args = append(args, "--state=kube:"+cfg.KubeSecret)
if cfg.StateDir == "" {
cfg.StateDir = "/tmp"
}
fallthrough
case cfg.StateDir != "":
args = append(args, "--statedir="+cfg.StateDir)
default:
args = append(args, "--state=mem:", "--statedir=/tmp")
}
if cfg.UserspaceMode {
args = append(args, "--tun=userspace-networking")
} else if err := ensureTunFile(cfg.Root); err != nil {
log.Fatalf("ensuring that /dev/net/tun exists: %v", err)
}
if cfg.SOCKSProxyAddr != "" {
args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr)
}
if cfg.HTTPProxyAddr != "" {
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
}
if cfg.TailscaledConfigFilePath != "" {
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
}
if cfg.DaemonExtraArgs != "" {
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
}
return args
}
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleUp(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "up"}
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
}
if cfg.AuthKey != "" {
args = append(args, "--authkey="+cfg.AuthKey)
}
// --advertise-routes can be passed an empty string to configure a
// device (that might have previously advertised subnet routes) to not
// advertise any routes. Respect an empty string passed by a user and
// use it to explicitly unset the routes.
if cfg.Routes != nil {
args = append(args, "--advertise-routes="+*cfg.Routes)
}
if cfg.Hostname != "" {
args = append(args, "--hostname="+cfg.Hostname)
}
if cfg.ExtraArgs != "" {
args = append(args, strings.Fields(cfg.ExtraArgs)...)
}
log.Printf("Running 'tailscale up'")
cmd := exec.CommandContext(ctx, "tailscale", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale up failed: %v", err)
}
return nil
}
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
// options that are passed in via environment variables. This is run after the
// node is in Running state and only if TS_AUTH_ONCE is set.
func tailscaleSet(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "set"}
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
}
// --advertise-routes can be passed an empty string to configure a
// device (that might have previously advertised subnet routes) to not
// advertise any routes. Respect an empty string passed by a user and
// use it to explicitly unset the routes.
if cfg.Routes != nil {
args = append(args, "--advertise-routes="+*cfg.Routes)
}
if cfg.Hostname != "" {
args = append(args, "--hostname="+cfg.Hostname)
}
log.Printf("Running 'tailscale set'")
cmd := exec.CommandContext(ctx, "tailscale", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale set failed: %v", err)
}
return nil
}
// ensureTunFile checks that /dev/net/tun exists, creating it if
// missing.
func ensureTunFile(root string) error {
@@ -884,350 +661,6 @@ func ensureTunFile(root string) error {
return nil
}
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error {
var (
v4Forwarding, v6Forwarding bool
)
if clusterProxyTargetIP != "" {
proxyIP, err := netip.ParseAddr(clusterProxyTargetIP)
if err != nil {
return fmt.Errorf("invalid cluster destination IP: %v", err)
}
if proxyIP.Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
if tailnetTargetIP != "" {
proxyIP, err := netip.ParseAddr(tailnetTargetIP)
if err != nil {
return fmt.Errorf("invalid tailnet destination IP: %v", err)
}
if proxyIP.Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
// Currently we only proxy traffic to the IPv4 address of the tailnet
// target.
if tailnetTargetFQDN != "" {
v4Forwarding = true
}
if routes != nil && *routes != "" {
for _, route := range strings.Split(*routes, ",") {
cidr, err := netip.ParsePrefix(route)
if err != nil {
return fmt.Errorf("invalid subnet route: %v", err)
}
if cidr.Addr().Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
}
return enableIPForwarding(v4Forwarding, v6Forwarding, root)
}
func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
var paths []string
if v4Forwarding {
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
}
if v6Forwarding {
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding"))
}
// In some common configurations (e.g. default docker,
// kubernetes), the container environment denies write access to
// most sysctls, including IP forwarding controls. Check the
// sysctl values before trying to change them, so that we
// gracefully do nothing if the container's already been set up
// properly by e.g. a k8s initContainer.
for _, path := range paths {
bs, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %q: %w", path, err)
}
if v := strings.TrimSpace(string(bs)); v != "1" {
if err := os.WriteFile(path, []byte("1"), 0644); err != nil {
return fmt.Errorf("enabling %q: %w", path, err)
}
}
}
return nil
}
func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
if err := nfr.AddSNATRuleForDst(local, dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
return nil
}
// installTSForwardingRuleForDestination accepts a destination address and a
// list of node's tailnet addresses, sets up rules to forward traffic for
// destination to the tailnet IP matching the destination IP family.
// Destination can be Pod IP of this node.
func installTSForwardingRuleForDestination(ctx context.Context, dstFilter string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstFilter)
if err != nil {
return err
}
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstFilter, tsIPs)
}
if err := nfr.AddDNATRule(dst, local); err != nil {
return fmt.Errorf("installing rule for forwarding traffic to tailnet IP: %w", err)
}
return nil
}
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
proxyHasIPv4Address := false
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() {
proxyHasIPv4Address = true
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if proxyHasIPv4Address && dst.Is6() {
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
if err := nfr.AddDNATRule(local, dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
return nil
}
func installIngressForwardingRuleForDNSTarget(ctx context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
var (
tsv4 netip.Addr
tsv6 netip.Addr
v4Backends []netip.Addr
v6Backends []netip.Addr
)
for _, pfx := range tsIPs {
if pfx.IsSingleIP() && pfx.Addr().Is4() {
tsv4 = pfx.Addr()
continue
}
if pfx.IsSingleIP() && pfx.Addr().Is6() {
tsv6 = pfx.Addr()
continue
}
}
// TODO: log if more than one backend address is found and firewall is
// in nftables mode that only the first IP will be used.
for _, ip := range backendAddrs {
if ip.To4() != nil {
v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4())))
}
if ip.To16() != nil {
v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16())))
}
}
// Enable IP forwarding here as opposed to at the start of containerboot
// as the IPv4/IPv6 requirements might have changed.
// For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is
// enabled by an init container, so in practice enabling forwarding here
// is only needed if this proxy has been configured by manually setting
// TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance.
if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil {
log.Printf("[unexpected] failed to ensure IP forwarding: %v", err)
}
updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error {
if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil {
return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err)
}
// The backend might advertize MSS higher than that of the
// tailscale interfaces. Clamp MSS of packets going out via
// tailscale0 interface to its MTU to prevent broken connections
// in environments where path MTU discovery is not working.
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err)
}
return nil
}
if len(v4Backends) != 0 {
if !tsv4.IsValid() {
log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs)
} else if err := updateFirewall(tsv4, v4Backends); err != nil {
return fmt.Errorf("Installing IPv4 firewall rules: %w", err)
}
}
if len(v6Backends) != 0 && !tsv6.IsValid() {
if !tsv6.IsValid() {
log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs)
} else if !nfr.HasIPV6NAT() {
log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs)
} else if err := updateFirewall(tsv6, v6Backends); err != nil {
return fmt.Errorf("Installing IPv6 firewall rules: %w", err)
}
}
return nil
}
// settings is all the configuration for containerboot.
type settings struct {
AuthKey string
Hostname string
Routes *string
// ProxyTargetIP is the destination IP to which all incoming
// Tailscale traffic should be proxied. If empty, no proxying
// is done. This is typically a locally reachable IP.
ProxyTargetIP string
// ProxyTargetDNSName is a DNS name to whose backing IP addresses all
// incoming Tailscale traffic should be proxied.
ProxyTargetDNSName string
// TailnetTargetIP is the destination IP to which all incoming
// non-Tailscale traffic should be proxied. This is typically a
// Tailscale IP.
TailnetTargetIP string
// TailnetTargetFQDN is an MagicDNS name to which all incoming
// non-Tailscale traffic should be proxied. This must be a full Tailnet
// node FQDN.
TailnetTargetFQDN string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
InKubernetes bool
UserspaceMode bool
StateDir string
AcceptDNS *bool
KubeSecret string
SOCKSProxyAddr string
HTTPProxyAddr string
Socket string
AuthOnce bool
Root string
KubernetesCanPatch bool
TailscaledConfigFilePath string
EnableForwardingOptimizations bool
// If set to true and, if this containerboot instance is a Kubernetes
// ingress proxy, set up rules to forward incoming cluster traffic to be
// forwarded to the ingress target in cluster.
AllowProxyingClusterTrafficViaIngress bool
// PodIP is the IP of the Pod if running in Kubernetes. This is used
// when setting up rules to proxy cluster traffic to cluster ingress
// target.
PodIP string
HealthCheckAddrPort string
}
func (s *settings) validate() error {
if s.TailscaledConfigFilePath != "" {
dir, file := path.Split(s.TailscaledConfigFilePath)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err)
}
if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err)
}
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
}
}
if s.ProxyTargetIP != "" && s.UserspaceMode {
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
}
if s.ProxyTargetDNSName != "" && s.UserspaceMode {
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE")
}
if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" {
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set")
}
if s.TailnetTargetIP != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
}
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
}
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
}
if s.AllowProxyingClusterTrafficViaIngress && s.ServeConfigPath == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but this is not a cluster ingress proxy")
}
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
}
if s.EnableForwardingOptimizations && s.UserspaceMode {
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
}
if s.HealthCheckAddrPort != "" {
if _, err := netip.ParseAddrPort(s.HealthCheckAddrPort); err != nil {
return fmt.Errorf("error parsing TS_HEALTH_CHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
}
}
return nil
}
func resolveDNS(ctx context.Context, name string) ([]net.IP, error) {
// TODO (irbekrm): look at using recursive.Resolver instead to resolve
// the DNS names as well as retrieve TTLs. It looks though that this
@@ -1250,57 +683,6 @@ func resolveDNS(ctx context.Context, name string) ([]net.IP, error) {
return append(ip4s, ip6s...), nil
}
// defaultEnv returns the value of the given envvar name, or defVal if
// unset.
func defaultEnv(name, defVal string) string {
if v, ok := os.LookupEnv(name); ok {
return v
}
return defVal
}
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being set to empty string vs unset.
func defaultEnvStringPointer(name string) *string {
if v, ok := os.LookupEnv(name); ok {
return &v
}
return nil
}
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being explicitly set to false vs unset.
func defaultEnvBoolPointer(name string) *bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return nil
}
return &ret
}
func defaultEnvs(names []string, defVal string) string {
for _, name := range names {
if v, ok := os.LookupEnv(name); ok {
return v
}
}
return defVal
}
// defaultBool returns the boolean value of the given envvar name, or
// defVal if unset or not a bool.
func defaultBool(name string, defVal bool) bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return defVal
}
return ret
}
// contextWithExitSignalWatch watches for SIGTERM/SIGINT signals. It returns a
// context that gets cancelled when a signal is received and a cancel function
// that can be called to free the resources when the watch should be stopped.
@@ -1323,43 +705,6 @@ func contextWithExitSignalWatch() (context.Context, func()) {
return ctx, f
}
// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured
// in two steps and login should only happen once.
// Step 1: run 'tailscaled'
// Step 2):
// A) if this is the first time starting this node run 'tailscale up --authkey <authkey> <config opts>'
// B) if this is not the first time starting this node run 'tailscale set <config opts>'.
func isTwoStepConfigAuthOnce(cfg *settings) bool {
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured
// in two steps and we should log in every time it starts.
// Step 1: run 'tailscaled'
// Step 2): run 'tailscale up --authkey <authkey> <config opts>'
func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// isOneStepConfig returns true if the Tailscale node should always be ran and
// configured in a single step by running 'tailscaled <config opts>'
func isOneStepConfig(cfg *settings) bool {
return cfg.TailscaledConfigFilePath != ""
}
// isL3Proxy returns true if the Tailscale node needs to be configured to act
// as an L3 proxy, proxying to an endpoint provided via one of the config env
// vars.
func isL3Proxy(cfg *settings) bool {
return cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
}
// hasKubeStateStore returns true if the state must be stored in a Kubernetes
// Secret.
func hasKubeStateStore(cfg *settings) bool {
return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != ""
}
// tailscaledConfigFilePath returns the path to the tailscaled config file that
// should be used for the current capability version. It is determined by the
// TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR environment variable and looks for a
@@ -1398,41 +743,3 @@ func tailscaledConfigFilePath() string {
log.Printf("Using tailscaled config file %q for capability version %q", maxCompatVer, tailcfg.CurrentCapabilityVersion)
return path.Join(dir, kubeutils.TailscaledConfigFileNameForCap(maxCompatVer))
}
// healthz is a simple health check server, if enabled it returns 200 OK if
// this tailscale node currently has at least one tailnet IP address else
// returns 503.
type healthz struct {
sync.Mutex
hasAddrs bool
}
func (h *healthz) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.Lock()
defer h.Unlock()
if h.hasAddrs {
w.Write([]byte("ok"))
} else {
http.Error(w, "node currently has no tailscale IPs", http.StatusInternalServerError)
}
}
// runHealthz runs a simple HTTP health endpoint on /healthz, listening on the
// provided address. A containerized tailscale instance is considered healthy if
// it has at least one tailnet IP address.
func runHealthz(addr string, h *healthz) {
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("error listening on the provided health endpoint address %q: %v", addr, err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", h)
log.Printf("Running healthcheck endpoint at %s/healthz", addr)
hs := &http.Server{Handler: mux}
go func() {
if err := hs.Serve(lis); err != nil {
log.Fatalf("failed running health endpoint: %v", err)
}
}()
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"bytes"
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"reflect"
"sync/atomic"
"time"
"github.com/fsnotify/fsnotify"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
)
// watchServeConfigChanges watches path for changes, and when it sees one, reads
// the serve config from it, replacing ${TS_CERT_DOMAIN} with certDomain, and
// applies it to lc. It exits when ctx is canceled. cdChanged is a channel that
// is written to when the certDomain changes, causing the serve config to be
// re-read and applied.
func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *tailscale.LocalClient) {
if certDomainAtomic == nil {
panic("cd must not be nil")
}
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
if w, err := fsnotify.NewWatcher(); err != nil {
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
tickChan = ticker.C
} else {
defer w.Close()
if err := w.Add(filepath.Dir(path)); err != nil {
log.Fatalf("failed to add fsnotify watch: %v", err)
}
eventChan = w.Events
}
var certDomain string
var prevServeConfig *ipn.ServeConfig
for {
select {
case <-ctx.Done():
return
case <-cdChanged:
certDomain = *certDomainAtomic.Load()
case <-tickChan:
case <-eventChan:
// We can't do any reasonable filtering on the event because of how
// k8s handles these mounts. So just re-read the file and apply it
// if it's changed.
}
if certDomain == "" {
continue
}
sc, err := readServeConfig(path, certDomain)
if err != nil {
log.Fatalf("failed to read serve config: %v", err)
}
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
continue
}
log.Printf("Applying serve config")
if err := lc.SetServeConfig(ctx, sc); err != nil {
log.Fatalf("failed to set serve config: %v", err)
}
prevServeConfig = sc
}
}
// readServeConfig reads the ipn.ServeConfig from path, replacing
// ${TS_CERT_DOMAIN} with certDomain.
func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
if path == "" {
return nil, nil
}
j, err := os.ReadFile(path)
if err != nil {
return nil, err
}
j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain))
var sc ipn.ServeConfig
if err := json.Unmarshal(j, &sc); err != nil {
return nil, err
}
return &sc, nil
}

View File

@@ -0,0 +1,259 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"errors"
"fmt"
"log"
"net/netip"
"os"
"path"
"strconv"
"tailscale.com/ipn/conffile"
"tailscale.com/kube/kubeclient"
)
// settings is all the configuration for containerboot.
type settings struct {
AuthKey string
Hostname string
Routes *string
// ProxyTargetIP is the destination IP to which all incoming
// Tailscale traffic should be proxied. If empty, no proxying
// is done. This is typically a locally reachable IP.
ProxyTargetIP string
// ProxyTargetDNSName is a DNS name to whose backing IP addresses all
// incoming Tailscale traffic should be proxied.
ProxyTargetDNSName string
// TailnetTargetIP is the destination IP to which all incoming
// non-Tailscale traffic should be proxied. This is typically a
// Tailscale IP.
TailnetTargetIP string
// TailnetTargetFQDN is an MagicDNS name to which all incoming
// non-Tailscale traffic should be proxied. This must be a full Tailnet
// node FQDN.
TailnetTargetFQDN string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
InKubernetes bool
UserspaceMode bool
StateDir string
AcceptDNS *bool
KubeSecret string
SOCKSProxyAddr string
HTTPProxyAddr string
Socket string
AuthOnce bool
Root string
KubernetesCanPatch bool
TailscaledConfigFilePath string
EnableForwardingOptimizations bool
// If set to true and, if this containerboot instance is a Kubernetes
// ingress proxy, set up rules to forward incoming cluster traffic to be
// forwarded to the ingress target in cluster.
AllowProxyingClusterTrafficViaIngress bool
// PodIP is the IP of the Pod if running in Kubernetes. This is used
// when setting up rules to proxy cluster traffic to cluster ingress
// target.
PodIP string
HealthCheckAddrPort string
}
func (s *settings) validate() error {
if s.TailscaledConfigFilePath != "" {
dir, file := path.Split(s.TailscaledConfigFilePath)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err)
}
if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err)
}
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
}
}
if s.ProxyTargetIP != "" && s.UserspaceMode {
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
}
if s.ProxyTargetDNSName != "" && s.UserspaceMode {
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE")
}
if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" {
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set")
}
if s.TailnetTargetIP != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
}
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
}
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
}
if s.AllowProxyingClusterTrafficViaIngress && s.ServeConfigPath == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but this is not a cluster ingress proxy")
}
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
}
if s.EnableForwardingOptimizations && s.UserspaceMode {
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
}
if s.HealthCheckAddrPort != "" {
if _, err := netip.ParseAddrPort(s.HealthCheckAddrPort); err != nil {
return fmt.Errorf("error parsing TS_HEALTH_CHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
}
}
return nil
}
// setupKube is responsible for doing any necessary configuration and checks to
// ensure that tailscale state storage and authentication mechanism will work on
// Kubernetes.
func (cfg *settings) setupKube(ctx context.Context) error {
if cfg.KubeSecret == "" {
return nil
}
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
if err != nil {
return fmt.Errorf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
}
cfg.KubernetesCanPatch = canPatch
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
if err != nil && kubeclient.IsNotFoundErr(err) && !canCreate {
return fmt.Errorf("Tailscale state Secret %s does not exist and we don't have permissions to create it. "+
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
} else if err != nil && !kubeclient.IsNotFoundErr(err) {
return fmt.Errorf("Getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
}
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
if s == nil {
log.Print("TS_AUTHKEY not provided and kube secret does not exist, login will be interactive if needed.")
return nil
}
keyBytes, _ := s.Data["authkey"]
key := string(keyBytes)
if key != "" {
// This behavior of pulling authkeys from kube secrets was added
// at the same time as the patch permission, so we can enforce
// that we must be able to patch out the authkey after
// authenticating if you want to use this feature. This avoids
// us having to deal with the case where we might leave behind
// an unnecessary reusable authkey in a secret, like a rake in
// the grass.
if !cfg.KubernetesCanPatch {
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
}
cfg.AuthKey = key
} else {
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
}
}
return nil
}
// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured
// in two steps and login should only happen once.
// Step 1: run 'tailscaled'
// Step 2):
// A) if this is the first time starting this node run 'tailscale up --authkey <authkey> <config opts>'
// B) if this is not the first time starting this node run 'tailscale set <config opts>'.
func isTwoStepConfigAuthOnce(cfg *settings) bool {
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured
// in two steps and we should log in every time it starts.
// Step 1: run 'tailscaled'
// Step 2): run 'tailscale up --authkey <authkey> <config opts>'
func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// isOneStepConfig returns true if the Tailscale node should always be ran and
// configured in a single step by running 'tailscaled <config opts>'
func isOneStepConfig(cfg *settings) bool {
return cfg.TailscaledConfigFilePath != ""
}
// isL3Proxy returns true if the Tailscale node needs to be configured to act
// as an L3 proxy, proxying to an endpoint provided via one of the config env
// vars.
func isL3Proxy(cfg *settings) bool {
return cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
}
// hasKubeStateStore returns true if the state must be stored in a Kubernetes
// Secret.
func hasKubeStateStore(cfg *settings) bool {
return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != ""
}
// defaultEnv returns the value of the given envvar name, or defVal if
// unset.
func defaultEnv(name, defVal string) string {
if v, ok := os.LookupEnv(name); ok {
return v
}
return defVal
}
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being set to empty string vs unset.
func defaultEnvStringPointer(name string) *string {
if v, ok := os.LookupEnv(name); ok {
return &v
}
return nil
}
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being explicitly set to false vs unset.
func defaultEnvBoolPointer(name string) *bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return nil
}
return &ret
}
func defaultEnvs(names []string, defVal string) string {
for _, name := range names {
if v, ok := os.LookupEnv(name); ok {
return v
}
}
return defVal
}
// defaultBool returns the boolean value of the given envvar name, or
// defVal if unset or not a bool.
func defaultBool(name string, defVal bool) bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return defVal
}
return ret
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"log"
"os"
"os/exec"
"strings"
"syscall"
"time"
"tailscale.com/client/tailscale"
)
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, *os.Process, error) {
args := tailscaledArgs(cfg)
// tailscaled runs without context, since it needs to persist
// beyond the startup timeout in ctx.
cmd := exec.Command("tailscaled", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
log.Printf("Starting tailscaled")
if err := cmd.Start(); err != nil {
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
}
// Wait for the socket file to appear, otherwise API ops will racily fail.
log.Printf("Waiting for tailscaled socket")
for {
if ctx.Err() != nil {
log.Fatalf("Timed out waiting for tailscaled socket")
}
_, err := os.Stat(cfg.Socket)
if errors.Is(err, fs.ErrNotExist) {
time.Sleep(100 * time.Millisecond)
continue
} else if err != nil {
log.Fatalf("Waiting for tailscaled socket: %v", err)
}
break
}
tsClient := &tailscale.LocalClient{
Socket: cfg.Socket,
UseSocketOnly: true,
}
return tsClient, cmd.Process, nil
}
// tailscaledArgs uses cfg to construct the argv for tailscaled.
func tailscaledArgs(cfg *settings) []string {
args := []string{"--socket=" + cfg.Socket}
switch {
case cfg.InKubernetes && cfg.KubeSecret != "":
args = append(args, "--state=kube:"+cfg.KubeSecret)
if cfg.StateDir == "" {
cfg.StateDir = "/tmp"
}
fallthrough
case cfg.StateDir != "":
args = append(args, "--statedir="+cfg.StateDir)
default:
args = append(args, "--state=mem:", "--statedir=/tmp")
}
if cfg.UserspaceMode {
args = append(args, "--tun=userspace-networking")
} else if err := ensureTunFile(cfg.Root); err != nil {
log.Fatalf("ensuring that /dev/net/tun exists: %v", err)
}
if cfg.SOCKSProxyAddr != "" {
args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr)
}
if cfg.HTTPProxyAddr != "" {
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
}
if cfg.TailscaledConfigFilePath != "" {
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
}
if cfg.DaemonExtraArgs != "" {
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
}
return args
}
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleUp(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "up"}
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
}
if cfg.AuthKey != "" {
args = append(args, "--authkey="+cfg.AuthKey)
}
// --advertise-routes can be passed an empty string to configure a
// device (that might have previously advertised subnet routes) to not
// advertise any routes. Respect an empty string passed by a user and
// use it to explicitly unset the routes.
if cfg.Routes != nil {
args = append(args, "--advertise-routes="+*cfg.Routes)
}
if cfg.Hostname != "" {
args = append(args, "--hostname="+cfg.Hostname)
}
if cfg.ExtraArgs != "" {
args = append(args, strings.Fields(cfg.ExtraArgs)...)
}
log.Printf("Running 'tailscale up'")
cmd := exec.CommandContext(ctx, "tailscale", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale up failed: %v", err)
}
return nil
}
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
// options that are passed in via environment variables. This is run after the
// node is in Running state and only if TS_AUTH_ONCE is set.
func tailscaleSet(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "set"}
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
}
// --advertise-routes can be passed an empty string to configure a
// device (that might have previously advertised subnet routes) to not
// advertise any routes. Respect an empty string passed by a user and
// use it to explicitly unset the routes.
if cfg.Routes != nil {
args = append(args, "--advertise-routes="+*cfg.Routes)
}
if cfg.Hostname != "" {
args = append(args, "--hostname="+cfg.Hostname)
}
log.Printf("Running 'tailscale set'")
cmd := exec.CommandContext(ctx, "tailscale", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale set failed: %v", err)
}
return nil
}

View File

@@ -52,7 +52,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L 💣 github.com/tailscale/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+
@@ -99,6 +99,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/hostinfo from tailscale.com/net/netmon+
tailscale.com/ipn from tailscale.com/client/tailscale
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/metrics from tailscale.com/cmd/derper+
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
@@ -127,7 +128,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/tsweb from tailscale.com/cmd/derper
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/dnstype from tailscale.com/tailcfg+
tailscale.com/types/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/tailcfg+
tailscale.com/types/key from tailscale.com/client/tailscale+
@@ -163,6 +164,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
tailscale.com/util/usermetric from tailscale.com/health
tailscale.com/util/vizerror from tailscale.com/tailcfg+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
@@ -175,14 +177,15 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from tailscale.com/tka
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
golang.org/x/crypto/chacha20poly1305 from crypto/tls
golang.org/x/crypto/chacha20poly1305 from crypto/tls+
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/hkdf from crypto/tls
golang.org/x/crypto/hkdf from crypto/tls+
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/crypto/sha3 from crypto/internal/mlkem768+
W golang.org/x/exp/constraints from tailscale.com/util/winutil
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
@@ -256,6 +259,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
io from bufio+
io/fs from crypto/x509+
io/ioutil from github.com/mitchellh/go-ps+
iter from maps+
log from expvar+
log/internal from log
maps from tailscale.com/ipn+
@@ -271,7 +275,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
net/http from expvar+
net/http/httptrace from net/http+
net/http/internal from net/http
net/http/pprof from tailscale.com/tsweb+
net/http/pprof from tailscale.com/tsweb
net/netip from go4.org/netipx+
net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+
@@ -300,3 +304,4 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
unicode from bytes+
unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+
unique from net/netip

View File

@@ -26,6 +26,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
@@ -61,11 +62,11 @@ type ConnectorReconciler struct {
var (
// gaugeConnectorResources tracks the overall number of Connectors currently managed by this operator instance.
gaugeConnectorResources = clientmetric.NewGauge("k8s_connector_resources")
gaugeConnectorResources = clientmetric.NewGauge(kubetypes.MetricConnectorResourceCount)
// gaugeConnectorSubnetRouterResources tracks the number of Connectors managed by this operator instance that are subnet routers.
gaugeConnectorSubnetRouterResources = clientmetric.NewGauge("k8s_connector_subnetrouter_resources")
gaugeConnectorSubnetRouterResources = clientmetric.NewGauge(kubetypes.MetricConnectorWithSubnetRouterCount)
// gaugeConnectorExitNodeResources tracks the number of Connectors currently managed by this operator instance that are exit nodes.
gaugeConnectorExitNodeResources = clientmetric.NewGauge("k8s_connector_exitnode_resources")
gaugeConnectorExitNodeResources = clientmetric.NewGauge(kubetypes.MetricConnectorWithExitNodeCount)
)
func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {

View File

@@ -16,6 +16,7 @@ import (
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstest"
"tailscale.com/util/mak"
)
@@ -74,6 +75,7 @@ func TestConnector(t *testing.T) {
hostname: "test-connector",
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
app: kubetypes.AppConnector,
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
@@ -169,6 +171,7 @@ func TestConnector(t *testing.T) {
parentType: "connector",
subnetRoutes: "10.40.0.0/14",
hostname: "test-connector",
app: kubetypes.AppConnector,
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
@@ -254,6 +257,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
hostname: "test-connector",
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
app: kubetypes.AppConnector,
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)

View File

@@ -143,7 +143,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
L 💣 github.com/illarion/gonotify/v2 from tailscale.com/net/dns
github.com/imdario/mergo from k8s.io/client-go/tools/clientcmd
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
@@ -171,7 +171,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L github.com/mdlayher/sdnotify from tailscale.com/util/systemd
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+
github.com/miekg/dns from tailscale.com/net/dns/recursive
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
github.com/modern-go/concurrent from github.com/json-iterator/go
@@ -216,6 +216,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+
L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink
github.com/tailscale/peercred from tailscale.com/ipn/ipnauth
github.com/tailscale/web-client-prebuilt from tailscale.com/client/web
💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+
@@ -231,7 +232,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4
L github.com/u-root/uio/uio from github.com/insomniacslk/dhcp/dhcpv4+
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
go.uber.org/multierr from go.uber.org/zap+
@@ -690,7 +690,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/k8s-operator/sessionrecording/spdy from tailscale.com/k8s-operator/sessionrecording
tailscale.com/k8s-operator/sessionrecording/tsrecorder from tailscale.com/k8s-operator/sessionrecording+
tailscale.com/k8s-operator/sessionrecording/ws from tailscale.com/k8s-operator/sessionrecording
tailscale.com/kube from tailscale.com/cmd/k8s-operator+
tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore
tailscale.com/kube/kubetypes from tailscale.com/cmd/k8s-operator+
tailscale.com/licenses from tailscale.com/client/web
tailscale.com/log/filelogger from tailscale.com/logpolicy
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
@@ -754,6 +756,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/derp+
tailscale.com/tsweb/varz from tailscale.com/util/usermetric
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+
@@ -795,7 +798,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
💣 tailscale.com/util/osdiag from tailscale.com/ipn/localapi
W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal
tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal+
tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal
tailscale.com/util/progresstracking from tailscale.com/ipn/localapi
tailscale.com/util/race from tailscale.com/net/dns/resolver
tailscale.com/util/racebuild from tailscale.com/logpolicy
@@ -812,6 +815,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/util/testenv from tailscale.com/control/controlclient+
tailscale.com/util/truncate from tailscale.com/logtail
tailscale.com/util/uniq from tailscale.com/ipn/ipnlocal+
tailscale.com/util/usermetric from tailscale.com/health+
tailscale.com/util/vizerror from tailscale.com/tailcfg+
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
@@ -849,6 +853,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
golang.org/x/exp/maps from sigs.k8s.io/controller-runtime/pkg/cache+
golang.org/x/exp/slices from tailscale.com/cmd/k8s-operator+
@@ -950,6 +955,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
io from archive/tar+
io/fs from archive/tar+
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
iter from go/ast+
log from expvar+
log/internal from log+
log/slog from github.com/go-logr/logr+
@@ -987,7 +993,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
runtime/pprof from net/http/pprof+
runtime/trace from net/http/pprof
slices from encoding/base32+
sort from archive/tar+
sort from compress/flate+
strconv from archive/tar+
strings from archive/tar+
sync from archive/tar+
@@ -1000,3 +1006,4 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
unicode from bytes+
unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+
unique from net/netip

View File

@@ -27,6 +27,9 @@ rules:
- apiGroups: ["tailscale.com"]
resources: ["dnsconfigs", "dnsconfigs/status"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["tailscale.com"]
resources: ["recorders", "recorders/status"]
verbs: ["get", "list", "watch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -56,6 +59,9 @@ rules:
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
verbs: ["get", "create", "patch", "update", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding

View File

@@ -89,14 +89,14 @@ spec:
type: object
properties:
image:
description: Nameserver image.
description: Nameserver image. Defaults to tailscale/k8s-nameserver:unstable.
type: object
properties:
repo:
description: Repo defaults to tailscale/k8s-nameserver.
type: string
tag:
description: Tag defaults to operator's own tag.
description: Tag defaults to unstable.
type: string
status:
description: |-

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
apiVersion: tailscale.com/v1alpha1
kind: Recorder
metadata:
name: recorder
spec:
enableUI: true

File diff suppressed because it is too large Load Diff

View File

@@ -24,10 +24,12 @@ const (
connectorCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml"
proxyClassCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxyclasses.yaml"
dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml"
recorderCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_recorders.yaml"
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml"
recorderCRDHelmTemplatePath = helmTemplatesPath + "/recorder.yaml"
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
helmConditionalEnd = "{{- end -}}"
@@ -111,7 +113,7 @@ func main() {
}
}
// generate places tailscale.com CRDs (currently Connector, ProxyClass and DNSConfig) into
// generate places tailscale.com CRDs (currently Connector, ProxyClass, DNSConfig, Recorder) into
// the Helm chart templates behind .Values.installCRDs=true condition (true by
// default).
func generate(baseDir string) error {
@@ -137,28 +139,32 @@ func generate(baseDir string) error {
}
return nil
}
if err := addCRDToHelm(connectorCRDPath, connectorCRDHelmTemplatePath); err != nil {
return fmt.Errorf("error adding Connector CRD to Helm templates: %w", err)
}
if err := addCRDToHelm(proxyClassCRDPath, proxyClassCRDHelmTemplatePath); err != nil {
return fmt.Errorf("error adding ProxyClass CRD to Helm templates: %w", err)
}
if err := addCRDToHelm(dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath); err != nil {
return fmt.Errorf("error adding DNSConfig CRD to Helm templates: %w", err)
for _, crd := range []struct {
crdPath, templatePath string
}{
{connectorCRDPath, connectorCRDHelmTemplatePath},
{proxyClassCRDPath, proxyClassCRDHelmTemplatePath},
{dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath},
{recorderCRDPath, recorderCRDHelmTemplatePath},
} {
if err := addCRDToHelm(crd.crdPath, crd.templatePath); err != nil {
return fmt.Errorf("error adding %s CRD to Helm templates: %w", crd.crdPath, err)
}
}
return nil
}
func cleanup(baseDir string) error {
log.Print("Cleaning up CRD from Helm templates")
if err := os.Remove(filepath.Join(baseDir, connectorCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up Connector CRD template: %w", err)
}
if err := os.Remove(filepath.Join(baseDir, proxyClassCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up ProxyClass CRD template: %w", err)
}
if err := os.Remove(filepath.Join(baseDir, dnsConfigCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up DNSConfig CRD template: %w", err)
for _, path := range []string{
connectorCRDHelmTemplatePath,
proxyClassCRDHelmTemplatePath,
dnsConfigCRDHelmTemplatePath,
recorderCRDHelmTemplatePath,
} {
if err := os.Remove(filepath.Join(baseDir, path)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up %s: %w", path, err)
}
}
return nil
}

View File

@@ -59,6 +59,9 @@ func Test_generate(t *testing.T) {
if !strings.Contains(installContentsWithCRD.String(), "name: dnsconfigs.tailscale.com") {
t.Errorf("DNSConfig CRD not found in default chart install")
}
if !strings.Contains(installContentsWithCRD.String(), "name: recorders.tailscale.com") {
t.Errorf("Recorder CRD not found in default chart install")
}
// Test that CRDs can be excluded from Helm chart install
installContentsWithoutCRD := bytes.NewBuffer([]byte{})
@@ -77,4 +80,7 @@ func Test_generate(t *testing.T) {
if strings.Contains(installContentsWithoutCRD.String(), "name: dnsconfigs.tailscale.com") {
t.Errorf("DNSConfig CRD found in chart install that should not contain a CRD")
}
if strings.Contains(installContentsWithoutCRD.String(), "name: recorders.tailscale.com") {
t.Errorf("Recorder CRD found in chart install that should not contain a CRD")
}
}

View File

@@ -23,6 +23,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/ipn"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/opt"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
@@ -53,7 +54,7 @@ type IngressReconciler struct {
var (
// gaugeIngressResources tracks the number of ingress resources that we're
// currently managing.
gaugeIngressResources = clientmetric.NewGauge("k8s_ingress_resources")
gaugeIngressResources = clientmetric.NewGauge(kubetypes.MetricIngressResourceCount)
)
func (a *IngressReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {

View File

@@ -17,6 +17,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"tailscale.com/ipn"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/ptr"
"tailscale.com/util/mak"
)
@@ -93,6 +94,7 @@ func TestTailscaleIngress(t *testing.T) {
namespace: "default",
parentType: "ingress",
hostname: "default-test",
app: kubetypes.AppIngressResource,
}
serveConfig := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
@@ -224,6 +226,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
namespace: "default",
parentType: "ingress",
hostname: "default-test",
app: kubetypes.AppIngressResource,
}
serveConfig := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},

View File

@@ -28,6 +28,7 @@ import (
"sigs.k8s.io/yaml"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
@@ -62,9 +63,7 @@ type NameserverReconciler struct {
managedNameservers set.Slice[types.UID] // one or none
}
var (
gaugeNameserverResources = clientmetric.NewGauge("k8s_nameserver_resources")
)
var gaugeNameserverResources = clientmetric.NewGauge(kubetypes.MetricNameserverCount)
func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
logger := a.logger.With("dnsConfig", req.Name)

View File

@@ -33,7 +33,7 @@ func TestNameserverReconciler(t *testing.T) {
},
Spec: tsapi.DNSConfigSpec{
Nameserver: &tsapi.Nameserver{
Image: &tsapi.Image{
Image: &tsapi.NameserverImage{
Repo: "test",
Tag: "v0.0.1",
},

View File

@@ -22,6 +22,7 @@ import (
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
networkingv1 "k8s.io/api/networking/v1"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/builder"
@@ -39,6 +40,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/store/kubestore"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tsnet"
"tailscale.com/tstime"
"tailscale.com/types/logger"
@@ -87,9 +89,9 @@ func main() {
// https://tailscale.com/kb/1236/kubernetes-operator/?q=kubernetes#accessing-the-kubernetes-control-plane-using-an-api-server-proxy.
mode := parseAPIProxyMode()
if mode == apiserverProxyModeDisabled {
hostinfo.SetApp("k8s-operator")
hostinfo.SetApp(kubetypes.AppOperator)
} else {
hostinfo.SetApp("k8s-operator-proxy")
hostinfo.SetApp(kubetypes.AppAPIServerProxy)
}
s, tsClient := initTSNet(zlog)
@@ -240,6 +242,8 @@ func runReconcilers(opts reconcilerOpts) {
&appsv1.StatefulSet{}: nsFilter,
&appsv1.Deployment{}: nsFilter,
&discoveryv1.EndpointSlice{}: nsFilter,
&rbacv1.Role{}: nsFilter,
&rbacv1.RoleBinding{}: nsFilter,
},
},
Scheme: tsapi.GlobalScheme,
@@ -300,10 +304,10 @@ func runReconcilers(opts reconcilerOpts) {
Watches(&corev1.Service{}, svcHandlerForIngress).
Watches(&tsapi.ProxyClass{}, proxyClassFilterForIngress).
Complete(&IngressReconciler{
ssr: ssr,
recorder: eventRecorder,
Client: mgr.GetClient(),
logger: opts.log.Named("ingress-reconciler"),
ssr: ssr,
recorder: eventRecorder,
Client: mgr.GetClient(),
logger: opts.log.Named("ingress-reconciler"),
proxyDefaultClass: opts.proxyDefaultClass,
})
if err != nil {
@@ -388,6 +392,28 @@ func runReconcilers(opts reconcilerOpts) {
if err != nil {
startlog.Fatalf("could not create DNS records reconciler: %v", err)
}
// Recorder reconciler.
recorderFilter := handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &tsapi.Recorder{})
err = builder.ControllerManagedBy(mgr).
For(&tsapi.Recorder{}).
Watches(&appsv1.StatefulSet{}, recorderFilter).
Watches(&corev1.ServiceAccount{}, recorderFilter).
Watches(&corev1.Secret{}, recorderFilter).
Watches(&rbacv1.Role{}, recorderFilter).
Watches(&rbacv1.RoleBinding{}, recorderFilter).
Complete(&RecorderReconciler{
recorder: eventRecorder,
tsNamespace: opts.tailscaleNamespace,
Client: mgr.GetClient(),
l: opts.log.Named("recorder-reconciler"),
clock: tstime.DefaultClock{},
tsClient: opts.tsClient,
})
if err != nil {
startlog.Fatalf("could not create Recorder reconciler: %v", err)
}
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)
@@ -524,6 +550,7 @@ func dnsRecordsReconcilerIngressHandler(ns string, isDefaultLoadBalancer bool, c
type tsClient interface {
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error)
DeleteDevice(ctx context.Context, nodeStableID string) error
}

View File

@@ -22,6 +22,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/net/dns/resolvconffile"
"tailscale.com/tstest"
"tailscale.com/tstime"
@@ -123,6 +124,7 @@ func TestLoadBalancerClass(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
@@ -260,6 +262,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
parentType: "svc",
tailnetTargetFQDN: tailnetTargetFQDN,
hostname: "default-test",
app: kubetypes.AppEgressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
@@ -371,6 +374,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
parentType: "svc",
tailnetTargetIP: tailnetTargetIP,
hostname: "default-test",
app: kubetypes.AppEgressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
@@ -479,6 +483,7 @@ func TestAnnotations(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
@@ -584,6 +589,7 @@ func TestAnnotationIntoLB(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
@@ -713,6 +719,7 @@ func TestLBIntoAnnotation(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
@@ -852,6 +859,7 @@ func TestCustomHostname(t *testing.T) {
parentType: "svc",
hostname: "reindeer-flotilla",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
@@ -964,6 +972,7 @@ func TestCustomPriorityClassName(t *testing.T) {
hostname: "tailscale-critical",
priorityClassName: "custom-priority-class-name",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
@@ -1032,6 +1041,7 @@ func TestProxyClassForService(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
@@ -1125,6 +1135,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
@@ -1181,6 +1192,7 @@ func TestProxyFirewallMode(t *testing.T) {
hostname: "default-test",
firewallMode: "nftables",
clusterTargetIP: "10.20.30.40",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
}
@@ -1235,6 +1247,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "e09bededa0379920141cbd0b0dbdf9b8b66545877f9e8397423f5ce3e1ba439e",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
@@ -1529,6 +1542,7 @@ func Test_externalNameService(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetDNS: "foo.com",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)

View File

@@ -23,7 +23,7 @@ import (
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
ksr "tailscale.com/k8s-operator/sessionrecording"
tskube "tailscale.com/kube"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/util/clientmetric"
@@ -31,11 +31,10 @@ import (
"tailscale.com/util/set"
)
var whoIsKey = ctxkey.New("", (*apitype.WhoIsResponse)(nil))
var (
// counterNumRequestsproxies counts the number of API server requests proxied via this proxy.
counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied")
whoIsKey = ctxkey.New("", (*apitype.WhoIsResponse)(nil))
)
type apiServerProxyMode int
@@ -222,6 +221,12 @@ func (ap *apiserverProxy) serveExecWS(w http.ResponseWriter, r *http.Request) {
}
func (ap *apiserverProxy) execForProto(w http.ResponseWriter, r *http.Request, proto ksr.Protocol) {
const (
podNameKey = "pod"
namespaceNameKey = "namespace"
upgradeHeaderKey = "Upgrade"
)
who, err := ap.whoIs(r)
if err != nil {
ap.authError(w, err)
@@ -246,7 +251,7 @@ func (ap *apiserverProxy) execForProto(w http.ResponseWriter, r *http.Request, p
}
wantsHeader := upgradeHeaderForProto[proto]
if h := r.Header.Get("Upgrade"); h != wantsHeader {
if h := r.Header.Get(upgradeHeaderKey); h != wantsHeader {
msg := fmt.Sprintf("[unexpected] unable to verify that streaming protocol is %s, wants Upgrade header %q, got: %q", proto, wantsHeader, h)
if failOpen {
msg = msg + "; failure mode is 'fail open'; continuing session without recording."
@@ -268,8 +273,8 @@ func (ap *apiserverProxy) execForProto(w http.ResponseWriter, r *http.Request, p
Who: who,
Addrs: addrs,
FailOpen: failOpen,
Pod: r.PathValue("pod"),
Namespace: r.PathValue("namespace"),
Pod: r.PathValue(podNameKey),
Namespace: r.PathValue(namespaceNameKey),
Log: ap.log,
}
h := ksr.New(opts)
@@ -309,9 +314,11 @@ func (h *apiserverProxy) addImpersonationHeadersAsRequired(r *http.Request) {
log.Printf("failed to add impersonation headers: " + err.Error())
}
}
func (ap *apiserverProxy) whoIs(r *http.Request) (*apitype.WhoIsResponse, error) {
return ap.lc.WhoIs(r.Context(), r.RemoteAddr)
}
func (ap *apiserverProxy) authError(w http.ResponseWriter, err error) {
ap.log.Errorf("failed to authenticate caller: %v", err)
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
@@ -332,10 +339,10 @@ const (
func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
log = log.With("remote", r.RemoteAddr)
who := whoIsKey.Value(r.Context())
rules, err := tailcfg.UnmarshalCapJSON[tskube.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
rules, err := tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
if len(rules) == 0 && err == nil {
// Try the old capability name for backwards compatibility.
rules, err = tailcfg.UnmarshalCapJSON[tskube.KubernetesCapRule](who.CapMap, oldCapabilityName)
rules, err = tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, oldCapabilityName)
}
if err != nil {
return fmt.Errorf("failed to unmarshal capability: %v", err)
@@ -385,7 +392,7 @@ func determineRecorderConfig(who *apitype.WhoIsResponse) (failOpen bool, recorde
return false, nil, errors.New("[unexpected] cannot determine caller")
}
failOpen = true
rules, err := tailcfg.UnmarshalCapJSON[tskube.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
rules, err := tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
if err != nil {
return failOpen, nil, fmt.Errorf("failed to unmarshal Kubernetes capability: %w", err)
}

View File

@@ -31,6 +31,7 @@ import (
"tailscale.com/ipn"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
@@ -342,7 +343,7 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
if len(tags) == 0 {
tags = a.defaultTags
}
authKey, err = a.newAuthKey(ctx, tags)
authKey, err = newAuthKey(ctx, a.tsClient, tags)
if err != nil {
return "", "", nil, err
}
@@ -418,6 +419,11 @@ func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map
if sec == nil {
return "", "", nil, nil
}
return deviceInfo(sec)
}
func deviceInfo(sec *corev1.Secret) (id tailcfg.StableNodeID, hostname string, ips []string, err error) {
id = tailcfg.StableNodeID(sec.Data["device_id"])
if id == "" {
return "", "", nil, nil
@@ -441,7 +447,7 @@ func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map
return id, hostname, ips, nil
}
func (a *tailscaleSTSReconciler) newAuthKey(ctx context.Context, tags []string) (string, error) {
func newAuthKey(ctx context.Context, tsClient tsClient, tags []string) (string, error) {
caps := tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
Create: tailscale.KeyDeviceCreateCapabilities{
@@ -452,7 +458,7 @@ func (a *tailscaleSTSReconciler) newAuthKey(ctx context.Context, tags []string)
},
}
key, _, err := a.tsClient.CreateKey(ctx, caps)
key, _, err := tsClient.CreateKey(ctx, caps)
if err != nil {
return "", err
}
@@ -598,6 +604,18 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
},
})
}
app, err := appInfoForProxy(sts)
if err != nil {
// No need to error out if now or in future we end up in a
// situation where app info cannot be determined for one of the
// many proxy configurations that the operator can produce.
logger.Error("[unexpected] unable to determine proxy type")
} else {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_INTERNAL_APP",
Value: app,
})
}
logger.Debugf("reconciling statefulset %s/%s", ss.GetNamespace(), ss.GetName())
if sts.ProxyClassName != "" {
logger.Debugf("configuring proxy resources with ProxyClass %s", sts.ProxyClassName)
@@ -611,6 +629,22 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
return createOrUpdate(ctx, a.Client, a.operatorNamespace, ss, updateSS)
}
func appInfoForProxy(cfg *tailscaleSTSConfig) (string, error) {
if cfg.ClusterTargetDNSName != "" || cfg.ClusterTargetIP != "" {
return kubetypes.AppIngressProxy, nil
}
if cfg.TailnetTargetFQDN != "" || cfg.TailnetTargetIP != "" {
return kubetypes.AppEgressProxy, nil
}
if cfg.ServeConfig != nil {
return kubetypes.AppIngressResource, nil
}
if cfg.Connector != nil {
return kubetypes.AppConnector, nil
}
return "", errors.New("unable to determine proxy type")
}
// mergeStatefulSetLabelsOrAnnots returns a map that contains all keys/values
// present in 'custom' map as well as those keys/values from the current map
// whose keys are present in the 'managed' map. The reason why this merge is

View File

@@ -25,6 +25,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/net/dns/resolvconffile"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
@@ -69,10 +70,10 @@ type ServiceReconciler struct {
var (
// gaugeEgressProxies tracks the number of egress proxies that we're
// currently managing.
gaugeEgressProxies = clientmetric.NewGauge("k8s_egress_proxies")
gaugeEgressProxies = clientmetric.NewGauge(kubetypes.MetricEgressProxyCount)
// gaugeIngressProxies tracks the number of ingress proxies that we're
// currently managing.
gaugeIngressProxies = clientmetric.NewGauge("k8s_ingress_proxies")
gaugeIngressProxies = clientmetric.NewGauge(kubetypes.MetricIngressProxyCount)
)
func childResourceLabels(name, ns, typ string) map[string]string {
@@ -327,7 +328,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
if err != nil {
msg := fmt.Sprintf("failed to parse cluster IP: %v", err)
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, msg, a.clock, logger)
return fmt.Errorf(msg)
return errors.New(msg)
}
for _, ip := range tsIPs {
addr, err := netip.ParseAddr(ip)

View File

@@ -9,6 +9,7 @@ import (
"context"
"encoding/json"
"net/netip"
"reflect"
"strings"
"sync"
"testing"
@@ -51,6 +52,7 @@ type configOpts struct {
serveConfig *ipn.ServeConfig
shouldEnableForwardingClusterTrafficViaIngress bool
proxyClass string // configuration from the named ProxyClass should be applied to proxy resources
app string
}
func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet {
@@ -142,6 +144,10 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
volumes = append(volumes, corev1.Volume{Name: "serve-config", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName, Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}}})
tsContainer.VolumeMounts = append(tsContainer.VolumeMounts, corev1.VolumeMount{Name: "serve-config", ReadOnly: true, MountPath: "/etc/tailscaled"})
}
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
Name: "TS_INTERNAL_APP",
Value: opts.app,
})
ss := &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
@@ -224,6 +230,7 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
{Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"},
{Name: "TS_INTERNAL_APP", Value: opts.app},
},
ImagePullPolicy: "Always",
VolumeMounts: []corev1.VolumeMount{
@@ -481,7 +488,7 @@ func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want
modifier(got)
}
if diff := cmp.Diff(got, want); diff != "" {
t.Fatalf("unexpected object (-got +want):\n%s", diff)
t.Fatalf("unexpected %s (-got +want):\n%s", reflect.TypeOf(want).Elem().Name(), diff)
}
}
@@ -492,7 +499,7 @@ func expectMissing[T any, O ptrObject[T]](t *testing.T, client client.Client, ns
Name: name,
Namespace: ns,
}, obj); !apierrors.IsNotFound(err) {
t.Fatalf("object %s/%s unexpectedly present, wanted missing", ns, name)
t.Fatalf("%s %s/%s unexpectedly present, wanted missing", reflect.TypeOf(obj).Elem().Name(), ns, name)
}
}
@@ -586,6 +593,17 @@ func (c *fakeTSClient) CreateKey(ctx context.Context, caps tailscale.KeyCapabili
return "secret-authkey", k, nil
}
func (c *fakeTSClient) Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error) {
return &tailscale.Device{
DeviceID: deviceID,
Hostname: "test-device",
Addresses: []string{
"1.2.3.4",
"::1",
},
}, nil
}
func (c *fakeTSClient) DeleteDevice(ctx context.Context, deviceID string) error {
c.Lock()
defer c.Unlock()

View File

@@ -0,0 +1,375 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"sync"
"github.com/pkg/errors"
"go.uber.org/zap"
xslices "golang.org/x/exp/slices"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"tailscale.com/client/tailscale"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
const (
reasonRecorderCreationFailed = "RecorderCreationFailed"
reasonRecorderCreated = "RecorderCreated"
reasonRecorderInvalid = "RecorderInvalid"
currentProfileKey = "_current-profile"
)
var gaugeRecorderResources = clientmetric.NewGauge(kubetypes.MetricRecorderCount)
// RecorderReconciler syncs Recorder statefulsets with their definition in
// Recorder CRs.
type RecorderReconciler struct {
client.Client
l *zap.SugaredLogger
recorder record.EventRecorder
clock tstime.Clock
tsNamespace string
tsClient tsClient
mu sync.Mutex // protects following
recorders set.Slice[types.UID] // for recorders gauge
}
func (r *RecorderReconciler) logger(name string) *zap.SugaredLogger {
return r.l.With("Recorder", name)
}
func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
logger := r.logger(req.Name)
logger.Debugf("starting reconcile")
defer logger.Debugf("reconcile finished")
tsr := new(tsapi.Recorder)
err = r.Get(ctx, req.NamespacedName, tsr)
if apierrors.IsNotFound(err) {
logger.Debugf("Recorder not found, assuming it was deleted")
return reconcile.Result{}, nil
} else if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com Recorder: %w", err)
}
if markedForDeletion(tsr) {
logger.Debugf("Recorder is being deleted, cleaning up resources")
ix := xslices.Index(tsr.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
return reconcile.Result{}, nil
}
if done, err := r.maybeCleanup(ctx, tsr); err != nil {
return reconcile.Result{}, err
} else if !done {
logger.Debugf("Recorder resource cleanup not yet finished, will retry...")
return reconcile.Result{RequeueAfter: shortRequeue}, nil
}
tsr.Finalizers = slices.Delete(tsr.Finalizers, ix, ix+1)
if err := r.Update(ctx, tsr); err != nil {
return reconcile.Result{}, err
}
return reconcile.Result{}, nil
}
oldTSRStatus := tsr.Status.DeepCopy()
setStatusReady := func(tsr *tsapi.Recorder, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
tsoperator.SetRecorderCondition(tsr, tsapi.RecorderReady, status, reason, message, tsr.Generation, r.clock, logger)
if !apiequality.Semantic.DeepEqual(oldTSRStatus, tsr.Status) {
// An error encountered here should get returned by the Reconcile function.
if updateErr := r.Client.Status().Update(ctx, tsr); updateErr != nil {
err = errors.Wrap(err, updateErr.Error())
}
}
return reconcile.Result{}, err
}
if !slices.Contains(tsr.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 log that the high level, multi-reconcile
// operation is underway.
logger.Infof("ensuring Recorder is set up")
tsr.Finalizers = append(tsr.Finalizers, FinalizerName)
if err := r.Update(ctx, tsr); err != nil {
logger.Errorf("error adding finalizer: %w", err)
return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderCreationFailed, reasonRecorderCreationFailed)
}
}
if err := r.validate(tsr); err != nil {
logger.Errorf("error validating Recorder spec: %w", err)
message := fmt.Sprintf("Recorder is invalid: %s", err)
r.recorder.Eventf(tsr, corev1.EventTypeWarning, reasonRecorderInvalid, message)
return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderInvalid, message)
}
if err = r.maybeProvision(ctx, tsr); err != nil {
logger.Errorf("error creating Recorder resources: %w", err)
message := fmt.Sprintf("failed creating Recorder: %s", err)
r.recorder.Eventf(tsr, corev1.EventTypeWarning, reasonRecorderCreationFailed, message)
return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderCreationFailed, message)
}
logger.Info("Recorder resources synced")
return setStatusReady(tsr, metav1.ConditionTrue, reasonRecorderCreated, reasonRecorderCreated)
}
func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsr *tsapi.Recorder) error {
logger := r.logger(tsr.Name)
r.mu.Lock()
r.recorders.Add(tsr.UID)
gaugeRecorderResources.Set(int64(r.recorders.Len()))
r.mu.Unlock()
if err := r.ensureAuthSecretCreated(ctx, tsr); err != nil {
return fmt.Errorf("error creating secrets: %w", err)
}
// State secret is precreated so we can use the Recorder CR as its owner ref.
sec := tsrStateSecret(tsr, r.tsNamespace)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sec, func(s *corev1.Secret) {
s.ObjectMeta.Labels = sec.ObjectMeta.Labels
s.ObjectMeta.Annotations = sec.ObjectMeta.Annotations
s.ObjectMeta.OwnerReferences = sec.ObjectMeta.OwnerReferences
}); err != nil {
return fmt.Errorf("error creating state Secret: %w", err)
}
sa := tsrServiceAccount(tsr, r.tsNamespace)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sa, func(s *corev1.ServiceAccount) {
s.ObjectMeta.Labels = sa.ObjectMeta.Labels
s.ObjectMeta.Annotations = sa.ObjectMeta.Annotations
s.ObjectMeta.OwnerReferences = sa.ObjectMeta.OwnerReferences
}); err != nil {
return fmt.Errorf("error creating ServiceAccount: %w", err)
}
role := tsrRole(tsr, r.tsNamespace)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, func(r *rbacv1.Role) {
r.ObjectMeta.Labels = role.ObjectMeta.Labels
r.ObjectMeta.Annotations = role.ObjectMeta.Annotations
r.ObjectMeta.OwnerReferences = role.ObjectMeta.OwnerReferences
r.Rules = role.Rules
}); err != nil {
return fmt.Errorf("error creating Role: %w", err)
}
roleBinding := tsrRoleBinding(tsr, r.tsNamespace)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, roleBinding, func(r *rbacv1.RoleBinding) {
r.ObjectMeta.Labels = roleBinding.ObjectMeta.Labels
r.ObjectMeta.Annotations = roleBinding.ObjectMeta.Annotations
r.ObjectMeta.OwnerReferences = roleBinding.ObjectMeta.OwnerReferences
r.RoleRef = roleBinding.RoleRef
r.Subjects = roleBinding.Subjects
}); err != nil {
return fmt.Errorf("error creating RoleBinding: %w", err)
}
ss := tsrStatefulSet(tsr, r.tsNamespace)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, ss, func(s *appsv1.StatefulSet) {
s.ObjectMeta.Labels = ss.ObjectMeta.Labels
s.ObjectMeta.Annotations = ss.ObjectMeta.Annotations
s.ObjectMeta.OwnerReferences = ss.ObjectMeta.OwnerReferences
s.Spec = ss.Spec
}); err != nil {
return fmt.Errorf("error creating StatefulSet: %w", err)
}
var devices []tsapi.TailnetDevice
device, ok, err := r.getDeviceInfo(ctx, tsr.Name)
if err != nil {
return fmt.Errorf("failed to get device info: %w", err)
}
if !ok {
logger.Debugf("no Tailscale hostname known yet, waiting for Recorder pod to finish auth")
return nil
}
devices = append(devices, device)
tsr.Status.Devices = devices
return nil
}
// maybeCleanup just deletes the device from the tailnet. All the kubernetes
// resources linked to a Recorder will get cleaned up via owner references
// (which we can use because they are all in the same namespace).
func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Recorder) (bool, error) {
logger := r.logger(tsr.Name)
id, _, ok, err := r.getNodeMetadata(ctx, tsr.Name)
if err != nil {
return false, err
}
if !ok {
logger.Debugf("state Secret %s-0 not found or does not contain node ID, continuing cleanup", tsr.Name)
r.mu.Lock()
r.recorders.Remove(tsr.UID)
gaugeRecorderResources.Set(int64(r.recorders.Len()))
r.mu.Unlock()
return true, nil
}
logger.Debugf("deleting device %s from control", string(id))
if err := r.tsClient.DeleteDevice(ctx, string(id)); err != nil {
errResp := &tailscale.ErrResponse{}
if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound {
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id))
} else {
return false, fmt.Errorf("error deleting device: %w", err)
}
} else {
logger.Debugf("device %s deleted from control", string(id))
}
// 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("cleaned up Recorder resources")
r.mu.Lock()
r.recorders.Remove(tsr.UID)
gaugeRecorderResources.Set(int64(r.recorders.Len()))
r.mu.Unlock()
return true, nil
}
func (r *RecorderReconciler) ensureAuthSecretCreated(ctx context.Context, tsr *tsapi.Recorder) error {
logger := r.logger(tsr.Name)
key := types.NamespacedName{
Namespace: r.tsNamespace,
Name: tsr.Name,
}
if err := r.Get(ctx, key, &corev1.Secret{}); err == nil {
// No updates, already created the auth key.
logger.Debugf("auth Secret %s already exists", key.Name)
return nil
} else if !apierrors.IsNotFound(err) {
return err
}
// Create the auth key Secret which is going to be used by the StatefulSet
// to authenticate with Tailscale.
logger.Debugf("creating authkey for new Recorder")
tags := tsr.Spec.Tags
if len(tags) == 0 {
tags = tsapi.Tags{"tag:k8s"}
}
authKey, err := newAuthKey(ctx, r.tsClient, tags.Stringify())
if err != nil {
return err
}
logger.Debug("creating a new Secret for the Recorder")
if err := r.Create(ctx, tsrAuthSecret(tsr, r.tsNamespace, authKey)); err != nil {
return err
}
return nil
}
func (r *RecorderReconciler) validate(tsr *tsapi.Recorder) error {
if !tsr.Spec.EnableUI && tsr.Spec.Storage.S3 == nil {
return errors.New("must either enable UI or use S3 storage to ensure recordings are accessible")
}
return nil
}
// getNodeMetadata returns 'ok == true' iff the node ID is found. The dnsName
// is expected to always be non-empty if the node ID is, but not required.
func (r *RecorderReconciler) getNodeMetadata(ctx context.Context, tsrName string) (id tailcfg.StableNodeID, dnsName string, ok bool, err error) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: r.tsNamespace,
Name: fmt.Sprintf("%s-0", tsrName),
},
}
if err := r.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil {
if apierrors.IsNotFound(err) {
return "", "", false, nil
}
return "", "", false, err
}
// TODO(tomhjp): Should maybe use ipn to parse the following info instead.
currentProfile, ok := secret.Data[currentProfileKey]
if !ok {
return "", "", false, nil
}
profileBytes, ok := secret.Data[string(currentProfile)]
if !ok {
return "", "", false, nil
}
var profile profile
if err := json.Unmarshal(profileBytes, &profile); err != nil {
return "", "", false, fmt.Errorf("failed to extract node profile info from state Secret %s: %w", secret.Name, err)
}
ok = profile.Config.NodeID != ""
return tailcfg.StableNodeID(profile.Config.NodeID), profile.Config.UserProfile.LoginName, ok, nil
}
func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsrName string) (d tsapi.TailnetDevice, ok bool, err error) {
nodeID, dnsName, ok, err := r.getNodeMetadata(ctx, tsrName)
if !ok || err != nil {
return tsapi.TailnetDevice{}, false, err
}
// TODO(tomhjp): The profile info doesn't include addresses, which is why we
// need the API. Should we instead update the profile to include addresses?
device, err := r.tsClient.Device(ctx, string(nodeID), nil)
if err != nil {
return tsapi.TailnetDevice{}, false, fmt.Errorf("failed to get device info from API: %w", err)
}
d = tsapi.TailnetDevice{
Hostname: device.Hostname,
TailnetIPs: device.Addresses,
}
if dnsName != "" {
d.URL = fmt.Sprintf("https://%s", dnsName)
}
return d, true, nil
}
type profile struct {
Config struct {
NodeID string `json:"NodeID"`
UserProfile struct {
LoginName string `json:"LoginName"`
} `json:"UserProfile"`
} `json:"Config"`
}
func markedForDeletion(tsr *tsapi.Recorder) bool {
return !tsr.DeletionTimestamp.IsZero()
}

View File

@@ -0,0 +1,278 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/types/ptr"
"tailscale.com/version"
)
func tsrStatefulSet(tsr *tsapi.Recorder, namespace string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: tsr.Name,
Namespace: namespace,
Labels: labels("recorder", tsr.Name, tsr.Spec.StatefulSet.Labels),
OwnerReferences: tsrOwnerReference(tsr),
Annotations: tsr.Spec.StatefulSet.Annotations,
},
Spec: appsv1.StatefulSetSpec{
Replicas: ptr.To[int32](1),
Selector: &metav1.LabelSelector{
MatchLabels: labels("recorder", tsr.Name, tsr.Spec.StatefulSet.Pod.Labels),
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Name: tsr.Name,
Namespace: namespace,
Labels: labels("recorder", tsr.Name, tsr.Spec.StatefulSet.Pod.Labels),
Annotations: tsr.Spec.StatefulSet.Pod.Annotations,
},
Spec: corev1.PodSpec{
ServiceAccountName: tsr.Name,
Affinity: tsr.Spec.StatefulSet.Pod.Affinity,
SecurityContext: tsr.Spec.StatefulSet.Pod.SecurityContext,
ImagePullSecrets: tsr.Spec.StatefulSet.Pod.ImagePullSecrets,
NodeSelector: tsr.Spec.StatefulSet.Pod.NodeSelector,
Tolerations: tsr.Spec.StatefulSet.Pod.Tolerations,
Containers: []corev1.Container{
{
Name: "recorder",
Image: func() string {
image := tsr.Spec.StatefulSet.Pod.Container.Image
if image == "" {
image = fmt.Sprintf("tailscale/tsrecorder:%s", selfVersionImageTag())
}
return image
}(),
ImagePullPolicy: tsr.Spec.StatefulSet.Pod.Container.ImagePullPolicy,
Resources: tsr.Spec.StatefulSet.Pod.Container.Resources,
SecurityContext: tsr.Spec.StatefulSet.Pod.Container.SecurityContext,
Env: env(tsr),
EnvFrom: func() []corev1.EnvFromSource {
if tsr.Spec.Storage.S3 == nil || tsr.Spec.Storage.S3.Credentials.Secret.Name == "" {
return nil
}
return []corev1.EnvFromSource{{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: tsr.Spec.Storage.S3.Credentials.Secret.Name,
},
},
}}
}(),
Command: []string{"/tsrecorder"},
VolumeMounts: []corev1.VolumeMount{
{
Name: "data",
MountPath: "/data",
ReadOnly: false,
},
},
},
},
Volumes: []corev1.Volume{
{
Name: "data",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
},
},
},
},
}
}
func tsrServiceAccount(tsr *tsapi.Recorder, namespace string) *corev1.ServiceAccount {
return &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: tsr.Name,
Namespace: namespace,
Labels: labels("recorder", tsr.Name, nil),
OwnerReferences: tsrOwnerReference(tsr),
},
}
}
func tsrRole(tsr *tsapi.Recorder, namespace string) *rbacv1.Role {
return &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: tsr.Name,
Namespace: namespace,
Labels: labels("recorder", tsr.Name, nil),
OwnerReferences: tsrOwnerReference(tsr),
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Resources: []string{"secrets"},
Verbs: []string{
"get",
"patch",
"update",
},
ResourceNames: []string{
tsr.Name, // Contains the auth key.
fmt.Sprintf("%s-0", tsr.Name), // Contains the node state.
},
},
},
}
}
func tsrRoleBinding(tsr *tsapi.Recorder, namespace string) *rbacv1.RoleBinding {
return &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: tsr.Name,
Namespace: namespace,
Labels: labels("recorder", tsr.Name, nil),
OwnerReferences: tsrOwnerReference(tsr),
},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: tsr.Name,
Namespace: namespace,
},
},
RoleRef: rbacv1.RoleRef{
Kind: "Role",
Name: tsr.Name,
},
}
}
func tsrAuthSecret(tsr *tsapi.Recorder, namespace string, authKey string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: tsr.Name,
Labels: labels("recorder", tsr.Name, nil),
OwnerReferences: tsrOwnerReference(tsr),
},
StringData: map[string]string{
"authkey": authKey,
},
}
}
func tsrStateSecret(tsr *tsapi.Recorder, namespace string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-0", tsr.Name),
Namespace: namespace,
Labels: labels("recorder", tsr.Name, nil),
OwnerReferences: tsrOwnerReference(tsr),
},
}
}
func env(tsr *tsapi.Recorder) []corev1.EnvVar {
envs := []corev1.EnvVar{
{
Name: "TS_AUTHKEY",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{
Name: tsr.Name,
},
Key: "authkey",
},
},
},
{
Name: "POD_NAME",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
// Secret is named after the pod.
FieldPath: "metadata.name",
},
},
},
{
Name: "TS_STATE",
Value: "kube:$(POD_NAME)",
},
{
Name: "TSRECORDER_HOSTNAME",
Value: "$(POD_NAME)",
},
}
for _, env := range tsr.Spec.StatefulSet.Pod.Container.Env {
envs = append(envs, corev1.EnvVar{
Name: string(env.Name),
Value: env.Value,
})
}
if tsr.Spec.Storage.S3 != nil {
envs = append(envs,
corev1.EnvVar{
Name: "TSRECORDER_DST",
Value: fmt.Sprintf("s3://%s", tsr.Spec.Storage.S3.Endpoint),
},
corev1.EnvVar{
Name: "TSRECORDER_BUCKET",
Value: tsr.Spec.Storage.S3.Bucket,
},
)
} else {
envs = append(envs, corev1.EnvVar{
Name: "TSRECORDER_DST",
Value: "/data/recordings",
})
}
if tsr.Spec.EnableUI {
envs = append(envs, corev1.EnvVar{
Name: "TSRECORDER_UI",
Value: "true",
})
}
return envs
}
func labels(app, instance string, customLabels map[string]string) map[string]string {
l := make(map[string]string, len(customLabels)+3)
for k, v := range customLabels {
l[k] = v
}
// ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
l["app.kubernetes.io/name"] = app
l["app.kubernetes.io/instance"] = instance
l["app.kubernetes.io/managed-by"] = "tailscale-operator"
return l
}
func tsrOwnerReference(owner metav1.Object) []metav1.OwnerReference {
return []metav1.OwnerReference{*metav1.NewControllerRef(owner, tsapi.SchemeGroupVersion.WithKind("Recorder"))}
}
// selfVersionImageTag returns the container image tag of the running operator
// build.
func selfVersionImageTag() string {
meta := version.GetMeta()
var versionPrefix string
if meta.UnstableBranch {
versionPrefix = "unstable-"
}
return fmt.Sprintf("%sv%s", versionPrefix, meta.MajorMinorPatch)
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"testing"
"github.com/google/go-cmp/cmp"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/types/ptr"
)
func TestRecorderSpecs(t *testing.T) {
t.Run("ensure spec fields are passed through correctly", func(t *testing.T) {
tsr := &tsapi.Recorder{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
Spec: tsapi.RecorderSpec{
StatefulSet: tsapi.RecorderStatefulSet{
Labels: map[string]string{
"ss-label-key": "ss-label-value",
},
Annotations: map[string]string{
"ss-annotation-key": "ss-annotation-value",
},
Pod: tsapi.RecorderPod{
Labels: map[string]string{
"pod-label-key": "pod-label-value",
},
Annotations: map[string]string{
"pod-annotation-key": "pod-annotation-value",
},
Affinity: &corev1.Affinity{
PodAffinity: &corev1.PodAffinity{
RequiredDuringSchedulingIgnoredDuringExecution: []corev1.PodAffinityTerm{{
LabelSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"match-label": "match-value",
},
}},
},
},
},
SecurityContext: &corev1.PodSecurityContext{
RunAsUser: ptr.To[int64](1000),
},
ImagePullSecrets: []corev1.LocalObjectReference{{
Name: "img-pull",
}},
NodeSelector: map[string]string{
"some-node": "selector",
},
Tolerations: []corev1.Toleration{{
Key: "key",
Value: "value",
TolerationSeconds: ptr.To[int64](60),
}},
Container: tsapi.RecorderContainer{
Env: []tsapi.Env{{
Name: "some_env",
Value: "env_value",
}},
Image: "custom-image",
ImagePullPolicy: corev1.PullAlways,
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{
"NET_ADMIN",
},
},
},
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("100m"),
},
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
},
},
},
},
},
},
}
ss := tsrStatefulSet(tsr, tsNamespace)
// StatefulSet-level.
if diff := cmp.Diff(ss.Annotations, tsr.Spec.StatefulSet.Annotations); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Annotations, tsr.Spec.StatefulSet.Pod.Annotations); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
// Pod-level.
if diff := cmp.Diff(ss.Labels, labels("recorder", "test", tsr.Spec.StatefulSet.Labels)); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Labels, labels("recorder", "test", tsr.Spec.StatefulSet.Pod.Labels)); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.Affinity, tsr.Spec.StatefulSet.Pod.Affinity); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.SecurityContext, tsr.Spec.StatefulSet.Pod.SecurityContext); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.ImagePullSecrets, tsr.Spec.StatefulSet.Pod.ImagePullSecrets); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.NodeSelector, tsr.Spec.StatefulSet.Pod.NodeSelector); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.Tolerations, tsr.Spec.StatefulSet.Pod.Tolerations); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
// Container-level.
if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].Env, env(tsr)); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].Image, tsr.Spec.StatefulSet.Pod.Container.Image); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].ImagePullPolicy, tsr.Spec.StatefulSet.Pod.Container.ImagePullPolicy); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].SecurityContext, tsr.Spec.StatefulSet.Pod.Container.SecurityContext); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].Resources, tsr.Spec.StatefulSet.Pod.Container.Resources); diff != "" {
t.Errorf("(-got +want):\n%s", diff)
}
})
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstest"
)
const tsNamespace = "tailscale"
func TestRecorder(t *testing.T) {
tsr := &tsapi.Recorder{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Finalizers: []string{"tailscale.com/finalizer"},
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(tsr).
WithStatusSubresource(tsr).
Build()
tsClient := &fakeTSClient{}
zl, _ := zap.NewDevelopment()
fr := record.NewFakeRecorder(1)
cl := tstest.NewClock(tstest.ClockOpts{})
reconciler := &RecorderReconciler{
tsNamespace: tsNamespace,
Client: fc,
tsClient: tsClient,
recorder: fr,
l: zl.Sugar(),
clock: cl,
}
t.Run("invalid spec gives an error condition", func(t *testing.T) {
expectReconciled(t, reconciler, "", tsr.Name)
msg := "Recorder is invalid: must either enable UI or use S3 storage to ensure recordings are accessible"
tsoperator.SetRecorderCondition(tsr, tsapi.RecorderReady, metav1.ConditionFalse, reasonRecorderInvalid, msg, 0, cl, zl.Sugar())
expectEqual(t, fc, tsr, nil)
if expected := 0; reconciler.recorders.Len() != expected {
t.Fatalf("expected %d recorders, got %d", expected, reconciler.recorders.Len())
}
expectRecorderResources(t, fc, tsr, false)
expectedEvent := "Warning RecorderInvalid Recorder is invalid: must either enable UI or use S3 storage to ensure recordings are accessible"
expectEvents(t, fr, []string{expectedEvent})
})
t.Run("observe Ready=true status condition for a valid spec", func(t *testing.T) {
tsr.Spec.EnableUI = true
mustUpdate(t, fc, "", "test", func(t *tsapi.Recorder) {
t.Spec = tsr.Spec
})
expectReconciled(t, reconciler, "", tsr.Name)
tsoperator.SetRecorderCondition(tsr, tsapi.RecorderReady, metav1.ConditionTrue, reasonRecorderCreated, reasonRecorderCreated, 0, cl, zl.Sugar())
expectEqual(t, fc, tsr, nil)
if expected := 1; reconciler.recorders.Len() != expected {
t.Fatalf("expected %d recorders, got %d", expected, reconciler.recorders.Len())
}
expectRecorderResources(t, fc, tsr, true)
})
t.Run("populate node info in state secret, and see it appear in status", func(t *testing.T) {
bytes, err := json.Marshal(map[string]any{
"Config": map[string]any{
"NodeID": "nodeid-123",
"UserProfile": map[string]any{
"LoginName": "test-0.example.ts.net",
},
},
})
if err != nil {
t.Fatal(err)
}
const key = "profile-abc"
mustUpdate(t, fc, tsNamespace, "test-0", func(s *corev1.Secret) {
s.Data = map[string][]byte{
currentProfileKey: []byte(key),
key: bytes,
}
})
expectReconciled(t, reconciler, "", tsr.Name)
tsr.Status.Devices = []tsapi.TailnetDevice{
{
Hostname: "test-device",
TailnetIPs: []string{"1.2.3.4", "::1"},
URL: "https://test-0.example.ts.net",
},
}
expectEqual(t, fc, tsr, nil)
})
t.Run("delete the Recorder and observe cleanup", func(t *testing.T) {
if err := fc.Delete(context.Background(), tsr); err != nil {
t.Fatal(err)
}
expectReconciled(t, reconciler, "", tsr.Name)
expectMissing[tsapi.Recorder](t, fc, "", tsr.Name)
if expected := 0; reconciler.recorders.Len() != expected {
t.Fatalf("expected %d recorders, got %d", expected, reconciler.recorders.Len())
}
if diff := cmp.Diff(tsClient.deleted, []string{"nodeid-123"}); diff != "" {
t.Fatalf("unexpected deleted devices (-got +want):\n%s", diff)
}
// The fake client does not clean up objects whose owner has been
// deleted, so we can't test for the owned resources getting deleted.
})
}
func expectRecorderResources(t *testing.T, fc client.WithWatch, tsr *tsapi.Recorder, shouldExist bool) {
t.Helper()
auth := tsrAuthSecret(tsr, tsNamespace, "secret-authkey")
state := tsrStateSecret(tsr, tsNamespace)
role := tsrRole(tsr, tsNamespace)
roleBinding := tsrRoleBinding(tsr, tsNamespace)
serviceAccount := tsrServiceAccount(tsr, tsNamespace)
statefulSet := tsrStatefulSet(tsr, tsNamespace)
if shouldExist {
expectEqual(t, fc, auth, nil)
expectEqual(t, fc, state, nil)
expectEqual(t, fc, role, nil)
expectEqual(t, fc, roleBinding, nil)
expectEqual(t, fc, serviceAccount, nil)
expectEqual(t, fc, statefulSet, nil)
} else {
expectMissing[corev1.Secret](t, fc, auth.Namespace, auth.Name)
expectMissing[corev1.Secret](t, fc, state.Namespace, state.Name)
expectMissing[rbacv1.Role](t, fc, role.Namespace, role.Name)
expectMissing[rbacv1.RoleBinding](t, fc, roleBinding.Namespace, roleBinding.Name)
expectMissing[corev1.ServiceAccount](t, fc, serviceAccount.Namespace, serviceAccount.Name)
expectMissing[appsv1.StatefulSet](t, fc, statefulSet.Namespace, statefulSet.Name)
}
}

View File

@@ -456,6 +456,11 @@ func (c *connector) ignoreDestination(dstAddrs []netip.Addr) bool {
}
func proxyTCPConn(c net.Conn, dest string) {
if c.RemoteAddr() == nil {
log.Printf("proxyTCPConn: nil RemoteAddr")
c.Close()
return
}
addrPortStr := c.LocalAddr().String()
_, port, err := net.SplitHostPort(addrPortStr)
if err != nil {
@@ -489,6 +494,9 @@ type perPeerState struct {
func (ps *perPeerState) domainForIP(ip netip.Addr) (_ string, ok bool) {
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.addrToDomain == nil {
return "", false
}
return ps.addrToDomain.Lookup(ip)
}

View File

@@ -50,6 +50,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
tailscale.com from tailscale.com/version
tailscale.com/envknob from tailscale.com/tsweb+
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/metrics from tailscale.com/net/stunserver+
tailscale.com/net/netaddr from tailscale.com/net/tsaddr
tailscale.com/net/stun from tailscale.com/net/stunserver
@@ -81,15 +82,16 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
tailscale.com/version/distro from tailscale.com/envknob
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
golang.org/x/crypto/chacha20poly1305 from crypto/tls
golang.org/x/crypto/chacha20poly1305 from crypto/tls+
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/hkdf from crypto/tls
golang.org/x/crypto/hkdf from crypto/tls+
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/net/dns/dnsmessage from net
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http
golang.org/x/net/http/httpproxy from net/http
golang.org/x/net/http2/hpack from net/http
@@ -153,6 +155,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
io from bufio+
io/fs from crypto/x509+
io/ioutil from google.golang.org/protobuf/internal/impl
iter from maps+
log from expvar+
log/internal from log
maps from tailscale.com/tailcfg+
@@ -168,7 +171,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
net/http from expvar+
net/http/httptrace from net/http
net/http/internal from net/http
net/http/pprof from tailscale.com/tsweb+
net/http/pprof from tailscale.com/tsweb
net/netip from go4.org/netipx+
net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+
@@ -195,3 +198,4 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
unicode from bytes+
unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+
unique from net/netip

View File

@@ -53,7 +53,16 @@ var (
)
const (
minInterval = time.Second
// maxTxJitter is the upper bounds for jitter introduced across probes
maxTXJitter = time.Millisecond * 400
// minInterval is the minimum allowed probe interval/step
minInterval = time.Second * 10
// txRxTimeout is the timeout value used for kernel timestamping loopback,
// and packet receive operations
txRxTimeout = time.Second * 2
// maxBufferDuration is the maximum duration (maxBufferDuration /
// *flagInterval steps worth) of buffered data that can be held in memory
// before data loss occurs around prometheus unavailability.
maxBufferDuration = time.Hour
)
@@ -322,7 +331,7 @@ func measureSTUNRTT(conn io.ReadWriteCloser, _ string, dst netip.AddrPort) (rtt
if !ok {
return 0, fmt.Errorf("unexpected conn type: %T", conn)
}
err = uconn.SetReadDeadline(time.Now().Add(time.Second * 2))
err = uconn.SetReadDeadline(time.Now().Add(txRxTimeout))
if err != nil {
return 0, fmt.Errorf("error setting read deadline: %w", err)
}
@@ -371,27 +380,6 @@ type nodeMeta struct {
type measureFn func(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error)
// probe measures round trip time for the node described by meta over cf against
// dstPort. It may return a nil duration and nil error in the event of a
// timeout. A non-nil error indicates an unrecoverable or non-temporary error.
func probe(meta nodeMeta, cf *connAndMeasureFn, dstPort int) (*time.Duration, error) {
ua := &net.UDPAddr{
IP: net.IP(meta.addr.AsSlice()),
Port: dstPort,
}
time.Sleep(rand.N(200 * time.Millisecond)) // jitter across tx
rtt, err := cf.fn(cf.conn, meta.hostname, netip.AddrPortFrom(meta.addr, uint16(dstPort)))
if err != nil {
if isTemporaryOrTimeoutErr(err) {
log.Printf("temp error measuring RTT to %s(%s): %v", meta.hostname, ua.String(), err)
return nil, nil
}
return nil, err
}
return &rtt, nil
}
// nodeMetaFromDERPMap parses the provided DERP map in order to update nodeMeta
// in the provided nodeMetaByAddr. It returns a slice of nodeMeta containing
// the nodes that are no longer seen in the DERP map, but were previously held
@@ -460,7 +448,7 @@ type connAndMeasureFn struct {
// newConnAndMeasureFn returns a connAndMeasureFn or an error. It may return
// nil for both if some combination of the supplied timestampSource, protocol,
// or connStability is unsupported.
func newConnAndMeasureFn(source timestampSource, protocol protocol, stable connStability) (*connAndMeasureFn, error) {
func newConnAndMeasureFn(forDst netip.Addr, source timestampSource, protocol protocol, stable connStability) (*connAndMeasureFn, error) {
info := getProtocolSupportInfo(protocol)
if !info.stableConn && bool(stable) {
return nil, nil
@@ -493,8 +481,14 @@ func newConnAndMeasureFn(source timestampSource, protocol protocol, stable connS
}, nil
}
case protocolICMP:
// TODO(jwhited): implement
return nil, nil
conn, err := getICMPConn(forDst, source)
if err != nil {
return nil, err
}
return &connAndMeasureFn{
conn: conn,
fn: mkICMPMeasureFn(source),
}, nil
case protocolHTTPS:
localPort := 0
if stable {
@@ -558,7 +552,7 @@ func getConns(
if !ok {
for _, source := range []timestampSource{timestampSourceUserspace, timestampSourceKernel} {
var cf *connAndMeasureFn
cf, err = newConnAndMeasureFn(source, protocol, stableConn)
cf, err = newConnAndMeasureFn(addr, source, protocol, stableConn)
if err != nil {
return
}
@@ -569,7 +563,7 @@ func getConns(
for _, source := range []timestampSource{timestampSourceUserspace, timestampSourceKernel} {
var cf *connAndMeasureFn
cf, err = newConnAndMeasureFn(source, protocol, unstableConn)
cf, err = newConnAndMeasureFn(addr, source, protocol, unstableConn)
if err != nil {
return
}
@@ -605,16 +599,24 @@ func probeNodes(nodeMetaByAddr map[netip.Addr]nodeMeta, stableConns map[stableCo
},
at: at,
}
rtt, err := probe(meta, cf, dstPort)
time.Sleep(rand.N(maxTXJitter)) // jitter across tx
addrPort := netip.AddrPortFrom(meta.addr, uint16(dstPort))
rtt, err := cf.fn(cf.conn, meta.hostname, addrPort)
if err != nil {
select {
case <-doneCh:
return
case errCh <- err:
return
if isTemporaryOrTimeoutErr(err) {
r.rtt = nil
log.Printf("%s: temp error measuring RTT to %s(%s): %v", protocol, meta.hostname, addrPort, err)
} else {
select {
case <-doneCh:
return
case errCh <- fmt.Errorf("%s: %v", protocol, err):
return
}
}
} else {
r.rtt = &rtt
}
r.rtt = rtt
select {
case <-doneCh:
case resultsCh <- r:
@@ -953,13 +955,6 @@ func main() {
log.Fatal("nothing to probe")
}
// TODO(jwhited): remove protocol restriction
for k := range portsByProtocol {
if k != protocolSTUN && k != protocolHTTPS && k != protocolTCP {
log.Fatal("ICMP is not yet supported")
}
}
if len(*flagDERPMap) < 1 {
log.Fatal("derp-map flag is unset")
}

View File

@@ -40,10 +40,26 @@ func getProtocolSupportInfo(p protocol) protocolSupportInfo {
userspaceTS: false,
stableConn: true,
}
case protocolICMP:
return protocolSupportInfo{
kernelTS: false,
userspaceTS: false,
stableConn: false,
}
}
return protocolSupportInfo{}
}
func getICMPConn(forDst netip.Addr, source timestampSource) (io.ReadWriteCloser, error) {
return nil, errors.New("platform unsupported")
}
func mkICMPMeasureFn(source timestampSource) measureFn {
return func(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) {
return 0, errors.New("platform unsupported")
}
}
func setSOReuseAddr(fd uintptr) error {
return nil
}

View File

@@ -10,17 +10,22 @@ import (
"errors"
"fmt"
"io"
"math"
"math/rand/v2"
"net/netip"
"syscall"
"time"
"github.com/mdlayher/socket"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"golang.org/x/sys/unix"
"tailscale.com/net/stun"
)
const (
flags = unix.SOF_TIMESTAMPING_TX_SOFTWARE | // tx timestamp generation in device driver
timestampingFlags = unix.SOF_TIMESTAMPING_TX_SOFTWARE | // tx timestamp generation in device driver
unix.SOF_TIMESTAMPING_RX_SOFTWARE | // rx timestamp generation in the kernel
unix.SOF_TIMESTAMPING_SOFTWARE // report software timestamps
)
@@ -35,7 +40,7 @@ func getUDPConnKernelTimestamp() (io.ReadWriteCloser, error) {
if err != nil {
return nil, err
}
err = sconn.SetsockoptInt(unix.SOL_SOCKET, unix.SO_TIMESTAMPING_NEW, flags)
err = sconn.SetsockoptInt(unix.SOL_SOCKET, unix.SO_TIMESTAMPING_NEW, timestampingFlags)
if err != nil {
return nil, err
}
@@ -57,7 +62,128 @@ func parseTimestampFromCmsgs(oob []byte) (time.Time, error) {
return time.Time{}, errors.New("failed to parse timestamp from cmsgs")
}
func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) {
func mkICMPMeasureFn(source timestampSource) measureFn {
return func(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) {
return measureICMPRTT(source, conn, hostname, dst)
}
}
func measureICMPRTT(source timestampSource, conn io.ReadWriteCloser, _ string, dst netip.AddrPort) (rtt time.Duration, err error) {
sconn, ok := conn.(*socket.Conn)
if !ok {
return 0, fmt.Errorf("conn of unexpected type: %T", conn)
}
txBody := &icmp.Echo{
// The kernel overrides this and routes appropriately so there is no
// point in setting or verifying.
ID: 0,
// Make this sufficiently random so that we do not account a late
// arriving reply in a future probe window.
Seq: int(rand.Int32N(math.MaxUint16)),
// Fingerprint ourselves.
Data: []byte("stunstamp"),
}
txMsg := icmp.Message{
Body: txBody,
}
var to unix.Sockaddr
if dst.Addr().Is4() {
txMsg.Type = ipv4.ICMPTypeEcho
to = &unix.SockaddrInet4{}
copy(to.(*unix.SockaddrInet4).Addr[:], dst.Addr().AsSlice())
} else {
txMsg.Type = ipv6.ICMPTypeEchoRequest
to = &unix.SockaddrInet6{}
copy(to.(*unix.SockaddrInet6).Addr[:], dst.Addr().AsSlice())
}
txBuf, err := txMsg.Marshal(nil)
if err != nil {
return 0, err
}
txAt := time.Now()
err = sconn.Sendto(context.Background(), txBuf, 0, to)
if err != nil {
return 0, fmt.Errorf("sendto error: %v", err)
}
if source == timestampSourceKernel {
txCtx, txCancel := context.WithTimeout(context.Background(), txRxTimeout)
defer txCancel()
buf := make([]byte, 1024)
oob := make([]byte, 1024)
for {
n, oobn, _, _, err := sconn.Recvmsg(txCtx, buf, oob, unix.MSG_ERRQUEUE)
if err != nil {
return 0, fmt.Errorf("recvmsg (MSG_ERRQUEUE) error: %v", err) // don't wrap
}
buf = buf[:n]
// Spin until we find the message we sent. We get the full packet
// looped including eth header so match against the tail.
if n < len(txBuf) {
continue
}
txLoopedMsg, err := icmp.ParseMessage(txMsg.Type.Protocol(), buf[len(buf)-len(txBuf):])
if err != nil {
continue
}
txLoopedBody, ok := txLoopedMsg.Body.(*icmp.Echo)
if !ok || txLoopedBody.Seq != txBody.Seq || txLoopedMsg.Code != txMsg.Code ||
txLoopedMsg.Type != txLoopedMsg.Type || !bytes.Equal(txLoopedBody.Data, txBody.Data) {
continue
}
txAt, err = parseTimestampFromCmsgs(oob[:oobn])
if err != nil {
return 0, fmt.Errorf("failed to get tx timestamp: %v", err) // don't wrap
}
break
}
}
rxCtx, rxCancel := context.WithTimeout(context.Background(), txRxTimeout)
defer rxCancel()
rxBuf := make([]byte, 1024)
oob := make([]byte, 1024)
for {
n, oobn, _, _, err := sconn.Recvmsg(rxCtx, rxBuf, oob, 0)
if err != nil {
return 0, fmt.Errorf("recvmsg error: %w", err)
}
rxAt := time.Now()
rxMsg, err := icmp.ParseMessage(txMsg.Type.Protocol(), rxBuf[:n])
if err != nil {
continue
}
if txMsg.Type == ipv4.ICMPTypeEcho {
if rxMsg.Type != ipv4.ICMPTypeEchoReply {
continue
}
} else {
if rxMsg.Type != ipv6.ICMPTypeEchoReply {
continue
}
}
if rxMsg.Code != txMsg.Code {
continue
}
rxBody, ok := rxMsg.Body.(*icmp.Echo)
if !ok || rxBody.Seq != txBody.Seq || !bytes.Equal(rxBody.Data, txBody.Data) {
continue
}
if source == timestampSourceKernel {
rxAt, err = parseTimestampFromCmsgs(oob[:oobn])
if err != nil {
return 0, fmt.Errorf("failed to get rx timestamp: %v", err)
}
}
return rxAt.Sub(txAt), nil
}
}
func measureSTUNRTTKernel(conn io.ReadWriteCloser, _ string, dst netip.AddrPort) (rtt time.Duration, err error) {
sconn, ok := conn.(*socket.Conn)
if !ok {
return 0, fmt.Errorf("conn of unexpected type: %T", conn)
@@ -84,7 +210,7 @@ func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname string, dst netip.Ad
return 0, fmt.Errorf("sendto error: %v", err) // don't wrap
}
txCtx, txCancel := context.WithTimeout(context.Background(), time.Second*2)
txCtx, txCancel := context.WithTimeout(context.Background(), txRxTimeout)
defer txCancel()
buf := make([]byte, 1024)
@@ -110,7 +236,7 @@ func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname string, dst netip.Ad
break
}
rxCtx, rxCancel := context.WithTimeout(context.Background(), time.Second*2)
rxCtx, rxCancel := context.WithTimeout(context.Background(), txRxTimeout)
defer rxCancel()
for {
@@ -138,6 +264,23 @@ func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname string, dst netip.Ad
}
func getICMPConn(forDst netip.Addr, source timestampSource) (io.ReadWriteCloser, error) {
domain := unix.AF_INET
proto := unix.IPPROTO_ICMP
if forDst.Is6() {
domain = unix.AF_INET6
proto = unix.IPPROTO_ICMPV6
}
conn, err := socket.Socket(domain, unix.SOCK_DGRAM, proto, "icmp", nil)
if err != nil {
return nil, err
}
if source == timestampSourceKernel {
err = conn.SetsockoptInt(unix.SOL_SOCKET, unix.SO_TIMESTAMPING_NEW, timestampingFlags)
}
return conn, err
}
func getProtocolSupportInfo(p protocol) protocolSupportInfo {
switch p {
case protocolSTUN:
@@ -158,7 +301,12 @@ func getProtocolSupportInfo(p protocol) protocolSupportInfo {
userspaceTS: false,
stableConn: true,
}
// TODO(jwhited): add ICMP
case protocolICMP:
return protocolSupportInfo{
kernelTS: true,
userspaceTS: true,
stableConn: false,
}
}
return protocolSupportInfo{}
}

11
cmd/systray/README.md Normal file
View File

@@ -0,0 +1,11 @@
# systray
The systray command is a minimal Tailscale systray application for Linux.
It is designed to provide quick access to common operations like profile switching
and exit node selection.
## Supported platforms
The `fyne.io/systray` package we use supports Windows, macOS, Linux, and many BSDs,
so the systray application will likely work for the most part on those platforms.
Notifications currently only work on Linux, as that is the main target.

220
cmd/systray/logo.go Normal file
View File

@@ -0,0 +1,220 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build cgo || !darwin
package main
import (
"bytes"
"context"
"image/color"
"image/png"
"sync"
"time"
"fyne.io/systray"
"github.com/fogleman/gg"
)
// tsLogo represents the state of the 3x3 dot grid in the Tailscale logo.
// A 0 represents a gray dot, any other value is a white dot.
type tsLogo [9]byte
var (
// disconnected is all gray dots
disconnected = tsLogo{
0, 0, 0,
0, 0, 0,
0, 0, 0,
}
// connected is the normal Tailscale logo
connected = tsLogo{
0, 0, 0,
1, 1, 1,
0, 1, 0,
}
// loading is a special tsLogo value that is not meant to be rendered directly,
// but indicates that the loading animation should be shown.
loading = tsLogo{'l', 'o', 'a', 'd', 'i', 'n', 'g'}
// loadingIcons are shown in sequence as an animated loading icon.
loadingLogos = []tsLogo{
{
0, 1, 1,
1, 0, 1,
0, 0, 1,
},
{
0, 1, 1,
0, 0, 1,
0, 1, 0,
},
{
0, 1, 1,
0, 0, 0,
0, 0, 1,
},
{
0, 0, 1,
0, 1, 0,
0, 0, 0,
},
{
0, 1, 0,
0, 0, 0,
0, 0, 0,
},
{
0, 0, 0,
0, 0, 1,
0, 0, 0,
},
{
0, 0, 0,
0, 0, 0,
0, 0, 0,
},
{
0, 0, 1,
0, 0, 0,
0, 0, 0,
},
{
0, 0, 0,
0, 0, 0,
1, 0, 0,
},
{
0, 0, 0,
0, 0, 0,
1, 1, 0,
},
{
0, 0, 0,
1, 0, 0,
1, 1, 0,
},
{
0, 0, 0,
1, 1, 0,
0, 1, 0,
},
{
0, 0, 0,
1, 1, 0,
0, 1, 1,
},
{
0, 0, 0,
1, 1, 1,
0, 0, 1,
},
{
0, 1, 0,
0, 1, 1,
1, 0, 1,
},
}
)
var (
black = color.NRGBA{0, 0, 0, 255}
white = color.NRGBA{255, 255, 255, 255}
gray = color.NRGBA{255, 255, 255, 102}
)
// render returns a PNG image of the logo.
func (logo tsLogo) render() *bytes.Buffer {
const radius = 25
const borderUnits = 1
dim := radius * (8 + borderUnits*2)
dc := gg.NewContext(dim, dim)
dc.DrawRectangle(0, 0, float64(dim), float64(dim))
dc.SetColor(black)
dc.Fill()
for y := 0; y < 3; y++ {
for x := 0; x < 3; x++ {
px := (borderUnits + 1 + 3*x) * radius
py := (borderUnits + 1 + 3*y) * radius
col := white
if logo[y*3+x] == 0 {
col = gray
}
dc.DrawCircle(float64(px), float64(py), radius)
dc.SetColor(col)
dc.Fill()
}
}
b := bytes.NewBuffer(nil)
png.Encode(b, dc.Image())
return b
}
// setAppIcon renders logo and sets it as the systray icon.
func setAppIcon(icon tsLogo) {
if icon == loading {
startLoadingAnimation()
} else {
stopLoadingAnimation()
systray.SetIcon(icon.render().Bytes())
}
}
var (
loadingMu sync.Mutex // protects loadingCancel
// loadingCancel stops the loading animation in the systray icon.
// This is nil if the animation is not currently active.
loadingCancel func()
)
// startLoadingAnimation starts the animated loading icon in the system tray.
// The animation continues until [stopLoadingAnimation] is called.
// If the loading animation is already active, this func does nothing.
func startLoadingAnimation() {
loadingMu.Lock()
defer loadingMu.Unlock()
if loadingCancel != nil {
// loading icon already displayed
return
}
ctx := context.Background()
ctx, loadingCancel = context.WithCancel(ctx)
go func() {
t := time.NewTicker(500 * time.Millisecond)
var i int
for {
select {
case <-ctx.Done():
return
case <-t.C:
systray.SetIcon(loadingLogos[i].render().Bytes())
i++
if i >= len(loadingLogos) {
i = 0
}
}
}
}()
}
// stopLoadingAnimation stops the animated loading icon in the system tray.
// If the loading animation is not currently active, this func does nothing.
func stopLoadingAnimation() {
loadingMu.Lock()
defer loadingMu.Unlock()
if loadingCancel != nil {
loadingCancel()
loadingCancel = nil
}
}

258
cmd/systray/systray.go Normal file
View File

@@ -0,0 +1,258 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build cgo || !darwin
// The systray command is a minimal Tailscale systray application for Linux.
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"sync"
"time"
"fyne.io/systray"
"github.com/atotto/clipboard"
dbus "github.com/godbus/dbus/v5"
"github.com/toqueteos/webbrowser"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
var (
localClient tailscale.LocalClient
chState chan ipn.State // tailscale state changes
appIcon *os.File
)
func main() {
systray.Run(onReady, onExit)
}
// Menu represents the systray menu, its items, and the current Tailscale state.
type Menu struct {
mu sync.Mutex // protects the entire Menu
status *ipnstate.Status
connect *systray.MenuItem
disconnect *systray.MenuItem
self *systray.MenuItem
more *systray.MenuItem
quit *systray.MenuItem
eventCancel func() // cancel eventLoop
}
func onReady() {
log.Printf("starting")
ctx := context.Background()
setAppIcon(disconnected)
// dbus wants a file path for notification icons, so copy to a temp file.
appIcon, _ = os.CreateTemp("", "tailscale-systray.png")
io.Copy(appIcon, connected.render())
chState = make(chan ipn.State, 1)
status, err := localClient.Status(ctx)
if err != nil {
log.Print(err)
}
menu := new(Menu)
menu.rebuild(status)
go watchIPNBus(ctx)
}
// rebuild the systray menu based on the current Tailscale state.
//
// We currently rebuild the entire menu because it is not easy to update the existing menu.
// You cannot iterate over the items in a menu, nor can you remove some items like separators.
// So for now we rebuild the whole thing, and can optimize this later if needed.
func (menu *Menu) rebuild(status *ipnstate.Status) {
menu.mu.Lock()
defer menu.mu.Unlock()
if menu.eventCancel != nil {
menu.eventCancel()
}
menu.status = status
systray.ResetMenu()
menu.connect = systray.AddMenuItem("Connect", "")
menu.disconnect = systray.AddMenuItem("Disconnect", "")
menu.disconnect.Hide()
systray.AddSeparator()
if status != nil && status.Self != nil {
title := fmt.Sprintf("This Device: %s (%s)", status.Self.HostName, status.Self.TailscaleIPs[0])
menu.self = systray.AddMenuItem(title, "")
}
systray.AddSeparator()
menu.more = systray.AddMenuItem("More settings", "")
menu.more.Enable()
menu.quit = systray.AddMenuItem("Quit", "Quit the app")
menu.quit.Enable()
ctx := context.Background()
ctx, menu.eventCancel = context.WithCancel(ctx)
go menu.eventLoop(ctx)
}
// eventLoop is the main event loop for handling click events on menu items
// and responding to Tailscale state changes.
// This method does not return until ctx.Done is closed.
func (menu *Menu) eventLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case state := <-chState:
switch state {
case ipn.Running:
setAppIcon(loading)
status, err := localClient.Status(ctx)
if err != nil {
log.Printf("error getting tailscale status: %v", err)
}
menu.rebuild(status)
setAppIcon(connected)
menu.connect.SetTitle("Connected")
menu.connect.Disable()
menu.disconnect.Show()
menu.disconnect.Enable()
case ipn.NoState, ipn.Stopped:
menu.connect.SetTitle("Connect")
menu.connect.Enable()
menu.disconnect.Hide()
setAppIcon(disconnected)
case ipn.Starting:
setAppIcon(loading)
}
case <-menu.connect.ClickedCh:
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: true,
},
WantRunningSet: true,
})
if err != nil {
log.Print(err)
continue
}
case <-menu.disconnect.ClickedCh:
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: false,
},
WantRunningSet: true,
})
if err != nil {
log.Printf("disconnecting: %v", err)
continue
}
case <-menu.self.ClickedCh:
copyTailscaleIP(menu.status.Self)
case <-menu.more.ClickedCh:
webbrowser.Open("http://100.100.100.100/")
case <-menu.quit.ClickedCh:
systray.Quit()
}
}
}
// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState.
// This method does not return.
func watchIPNBus(ctx context.Context) {
for {
if err := watchIPNBusInner(ctx); err != nil {
log.Println(err)
if errors.Is(err, context.Canceled) {
// If the context got canceled, we will never be able to
// reconnect to IPN bus, so exit the process.
log.Fatalf("watchIPNBus: %v", err)
}
}
// If our watch connection breaks, wait a bit before reconnecting. No
// reason to spam the logs if e.g. tailscaled is restarting or goes
// down.
time.Sleep(3 * time.Second)
}
}
func watchIPNBusInner(ctx context.Context) error {
watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys)
if err != nil {
return fmt.Errorf("watching ipn bus: %w", err)
}
defer watcher.Close()
for {
select {
case <-ctx.Done():
return nil
default:
n, err := watcher.Next()
if err != nil {
return fmt.Errorf("ipnbus error: %w", err)
}
if n.State != nil {
chState <- *n.State
log.Printf("new state: %v", n.State)
}
}
}
}
// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard
// and sends a notification with the copied value.
func copyTailscaleIP(device *ipnstate.PeerStatus) {
if device == nil || len(device.TailscaleIPs) == 0 {
return
}
name := strings.Split(device.DNSName, ".")[0]
ip := device.TailscaleIPs[0].String()
err := clipboard.WriteAll(ip)
if err != nil {
log.Printf("clipboard error: %v", err)
}
sendNotification(fmt.Sprintf("Copied Address for %v", name), ip)
}
// sendNotification sends a desktop notification with the given title and content.
func sendNotification(title, content string) {
conn, err := dbus.SessionBus()
if err != nil {
log.Printf("dbus: %v", err)
return
}
timeout := 3 * time.Second
obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
call := obj.Call("org.freedesktop.Notifications.Notify", 0, "Tailscale", uint32(0),
appIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds()))
if call.Err != nil {
log.Printf("dbus: %v", call.Err)
}
}
func onExit() {
log.Printf("exiting")
os.Remove(appIcon.Name())
}

View File

@@ -187,6 +187,7 @@ change in the future.
configureCmd,
netcheckCmd,
ipCmd,
dnsCmd,
statusCmd,
pingCmd,
ncCmd,

View File

@@ -22,6 +22,7 @@ import (
"os"
"os/exec"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -319,9 +320,36 @@ var debugCmd = &ffcli.Command{
return fs
})(),
},
{
Name: "resolve",
ShortUsage: "tailscale debug resolve <hostname>",
Exec: runDebugResolve,
ShortHelp: "Does a DNS lookup",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("resolve")
fs.StringVar(&resolveArgs.net, "net", "ip", "network type to resolve (ip, ip4, ip6)")
return fs
})(),
},
{
Name: "go-buildinfo",
ShortUsage: "tailscale debug go-buildinfo",
ShortHelp: "Prints Go's runtime/debug.BuildInfo",
Exec: runGoBuildInfo,
},
},
}
func runGoBuildInfo(ctx context.Context, args []string) error {
bi, ok := debug.ReadBuildInfo()
if !ok {
return errors.New("no Go build info")
}
e := json.NewEncoder(os.Stdout)
e.SetIndent("", "\t")
return e.Encode(bi)
}
var debugArgs struct {
file string
cpuSec int
@@ -1167,3 +1195,26 @@ func runDebugDialTypes(ctx context.Context, args []string) error {
fmt.Printf("%s", body)
return nil
}
var resolveArgs struct {
net string // "ip", "ip4", "ip6""
}
func runDebugResolve(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("usage: tailscale debug resolve <hostname>")
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
host := args[0]
ips, err := net.DefaultResolver.LookupIP(ctx, resolveArgs.net, host)
if err != nil {
return err
}
for _, ip := range ips {
fmt.Printf("%s\n", ip)
}
return nil
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"flag"
"fmt"
"net/netip"
"os"
"text/tabwriter"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/dnstype"
)
func runDNSQuery(ctx context.Context, args []string) error {
if len(args) < 1 {
return flag.ErrHelp
}
name := args[0]
queryType := "A"
if len(args) >= 2 {
queryType = args[1]
}
fmt.Printf("DNS query for %q (%s) using internal resolver:\n", name, queryType)
fmt.Println()
bytes, resolvers, err := localClient.QueryDNS(ctx, name, queryType)
if err != nil {
fmt.Printf("failed to query DNS: %v\n", err)
return nil
}
if len(resolvers) == 1 {
fmt.Printf("Forwarding to resolver: %v\n", makeResolverString(*resolvers[0]))
} else {
fmt.Println("Multiple resolvers available:")
for _, r := range resolvers {
fmt.Printf(" - %v\n", makeResolverString(*r))
}
}
fmt.Println()
var p dnsmessage.Parser
header, err := p.Start(bytes)
if err != nil {
fmt.Printf("failed to parse DNS response: %v\n", err)
return err
}
fmt.Printf("Response code: %v\n", header.RCode.String())
fmt.Println()
p.SkipAllQuestions()
if header.RCode != dnsmessage.RCodeSuccess {
fmt.Println("No answers were returned.")
return nil
}
answers, err := p.AllAnswers()
if err != nil {
fmt.Printf("failed to parse DNS answers: %v\n", err)
return err
}
if len(answers) == 0 {
fmt.Println(" (no answers found)")
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Name\tTTL\tClass\tType\tBody")
fmt.Fprintln(w, "----\t---\t-----\t----\t----")
for _, a := range answers {
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\n", a.Header.Name.String(), a.Header.TTL, a.Header.Class.String(), a.Header.Type.String(), makeAnswerBody(a))
}
w.Flush()
fmt.Println()
return nil
}
// makeAnswerBody returns a string with the DNS answer body in a human-readable format.
func makeAnswerBody(a dnsmessage.Resource) string {
switch a.Header.Type {
case dnsmessage.TypeA:
return makeABody(a.Body)
case dnsmessage.TypeAAAA:
return makeAAAABody(a.Body)
case dnsmessage.TypeCNAME:
return makeCNAMEBody(a.Body)
case dnsmessage.TypeMX:
return makeMXBody(a.Body)
case dnsmessage.TypeNS:
return makeNSBody(a.Body)
case dnsmessage.TypeOPT:
return makeOPTBody(a.Body)
case dnsmessage.TypePTR:
return makePTRBody(a.Body)
case dnsmessage.TypeSRV:
return makeSRVBody(a.Body)
case dnsmessage.TypeTXT:
return makeTXTBody(a.Body)
default:
return a.Body.GoString()
}
}
func makeABody(a dnsmessage.ResourceBody) string {
if a, ok := a.(*dnsmessage.AResource); ok {
return netip.AddrFrom4(a.A).String()
}
return ""
}
func makeAAAABody(aaaa dnsmessage.ResourceBody) string {
if a, ok := aaaa.(*dnsmessage.AAAAResource); ok {
return netip.AddrFrom16(a.AAAA).String()
}
return ""
}
func makeCNAMEBody(cname dnsmessage.ResourceBody) string {
if c, ok := cname.(*dnsmessage.CNAMEResource); ok {
return c.CNAME.String()
}
return ""
}
func makeMXBody(mx dnsmessage.ResourceBody) string {
if m, ok := mx.(*dnsmessage.MXResource); ok {
return fmt.Sprintf("%s (Priority=%d)", m.MX, m.Pref)
}
return ""
}
func makeNSBody(ns dnsmessage.ResourceBody) string {
if n, ok := ns.(*dnsmessage.NSResource); ok {
return n.NS.String()
}
return ""
}
func makeOPTBody(opt dnsmessage.ResourceBody) string {
if o, ok := opt.(*dnsmessage.OPTResource); ok {
return o.GoString()
}
return ""
}
func makePTRBody(ptr dnsmessage.ResourceBody) string {
if p, ok := ptr.(*dnsmessage.PTRResource); ok {
return p.PTR.String()
}
return ""
}
func makeSRVBody(srv dnsmessage.ResourceBody) string {
if s, ok := srv.(*dnsmessage.SRVResource); ok {
return fmt.Sprintf("Target=%s, Port=%d, Priority=%d, Weight=%d", s.Target.String(), s.Port, s.Priority, s.Weight)
}
return ""
}
func makeTXTBody(txt dnsmessage.ResourceBody) string {
if t, ok := txt.(*dnsmessage.TXTResource); ok {
return fmt.Sprintf("%q", t.TXT)
}
return ""
}
func makeResolverString(r dnstype.Resolver) string {
if len(r.BootstrapResolution) > 0 {
return fmt.Sprintf("%s (bootstrap: %v)", r.Addr, r.BootstrapResolution)
}
return fmt.Sprintf("%s", r.Addr)
}

View File

@@ -0,0 +1,242 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"tailscale.com/ipn"
"tailscale.com/types/netmap"
)
// dnsStatusArgs are the arguments for the "dns status" subcommand.
var dnsStatusArgs struct {
all bool
}
func runDNSStatus(ctx context.Context, args []string) error {
all := dnsStatusArgs.all
s, err := localClient.Status(ctx)
if err != nil {
return err
}
prefs, err := localClient.GetPrefs(ctx)
if err != nil {
return err
}
enabledStr := "disabled.\n\n(Run 'tailscale set --accept-dns=true' to start sending DNS queries to the Tailscale DNS resolver)"
if prefs.CorpDNS {
enabledStr = "enabled.\n\nTailscale is configured to handle DNS queries on this device.\nRun 'tailscale set --accept-dns=false' to revert to your system default DNS resolver."
}
fmt.Print("\n")
fmt.Println("=== 'Use Tailscale DNS' status ===")
fmt.Print("\n")
fmt.Printf("Tailscale DNS: %s\n", enabledStr)
fmt.Print("\n")
fmt.Println("=== MagicDNS configuration ===")
fmt.Print("\n")
fmt.Println("This is the DNS configuration provided by the coordination server to this device.")
fmt.Print("\n")
if s.CurrentTailnet == nil {
fmt.Println("No tailnet information available; make sure you're logged in to a tailnet.")
return nil
} else if s.CurrentTailnet.MagicDNSEnabled {
fmt.Printf("MagicDNS: enabled tailnet-wide (suffix = %s)", s.CurrentTailnet.MagicDNSSuffix)
fmt.Print("\n\n")
fmt.Printf("Other devices in your tailnet can reach this device at %s\n", s.Self.DNSName)
} else {
fmt.Printf("MagicDNS: disabled tailnet-wide.\n")
}
fmt.Print("\n")
netMap, err := fetchNetMap()
if err != nil {
fmt.Printf("Failed to fetch network map: %v\n", err)
return err
}
dnsConfig := netMap.DNS
fmt.Println("Resolvers (in preference order):")
if len(dnsConfig.Resolvers) == 0 {
fmt.Println(" (no resolvers configured, system default will be used: see 'System DNS configuration' below)")
}
for _, r := range dnsConfig.Resolvers {
fmt.Printf(" - %v", r.Addr)
if r.BootstrapResolution != nil {
fmt.Printf(" (bootstrap: %v)", r.BootstrapResolution)
}
fmt.Print("\n")
}
fmt.Print("\n")
fmt.Println("Split DNS Routes:")
if len(dnsConfig.Routes) == 0 {
fmt.Println(" (no routes configured: split DNS disabled)")
}
for _, k := range slices.Sorted(maps.Keys(dnsConfig.Routes)) {
v := dnsConfig.Routes[k]
for _, r := range v {
fmt.Printf(" - %-30s -> %v", k, r.Addr)
if r.BootstrapResolution != nil {
fmt.Printf(" (bootstrap: %v)", r.BootstrapResolution)
}
fmt.Print("\n")
}
}
fmt.Print("\n")
if all {
fmt.Println("Fallback Resolvers:")
if len(dnsConfig.FallbackResolvers) == 0 {
fmt.Println(" (no fallback resolvers configured)")
}
for i, r := range dnsConfig.FallbackResolvers {
fmt.Printf(" %d: %v\n", i, r)
}
fmt.Print("\n")
}
fmt.Println("Search Domains:")
if len(dnsConfig.Domains) == 0 {
fmt.Println(" (no search domains configured)")
}
domains := dnsConfig.Domains
slices.Sort(domains)
for _, r := range domains {
fmt.Printf(" - %v\n", r)
}
fmt.Print("\n")
if all {
fmt.Println("Nameservers IP Addresses:")
if len(dnsConfig.Nameservers) == 0 {
fmt.Println(" (none were provided)")
}
for _, r := range dnsConfig.Nameservers {
fmt.Printf(" - %v\n", r)
}
fmt.Print("\n")
fmt.Println("Certificate Domains:")
if len(dnsConfig.CertDomains) == 0 {
fmt.Println(" (no certificate domains are configured)")
}
for _, r := range dnsConfig.CertDomains {
fmt.Printf(" - %v\n", r)
}
fmt.Print("\n")
fmt.Println("Additional DNS Records:")
if len(dnsConfig.ExtraRecords) == 0 {
fmt.Println(" (no extra records are configured)")
}
for _, er := range dnsConfig.ExtraRecords {
if er.Type == "" {
fmt.Printf(" - %-50s -> %v\n", er.Name, er.Value)
} else {
fmt.Printf(" - [%s] %-50s -> %v\n", er.Type, er.Name, er.Value)
}
}
fmt.Print("\n")
fmt.Println("Filtered suffixes when forwarding DNS queries as an exit node:")
if len(dnsConfig.ExitNodeFilteredSet) == 0 {
fmt.Println(" (no suffixes are filtered)")
}
for _, s := range dnsConfig.ExitNodeFilteredSet {
fmt.Printf(" - %s\n", s)
}
fmt.Print("\n")
}
fmt.Println("=== System DNS configuration ===")
fmt.Print("\n")
fmt.Println("This is the DNS configuration that Tailscale believes your operating system is using.\nTailscale may use this configuration if 'Override Local DNS' is disabled in the admin console,\nor if no resolvers are provided by the coordination server.")
fmt.Print("\n")
osCfg, err := localClient.GetDNSOSConfig(ctx)
if err != nil {
if strings.Contains(err.Error(), "not supported") {
// avoids showing the HTTP error code which would be odd here
fmt.Println(" (reading the system DNS configuration is not supported on this platform)")
} else {
fmt.Printf(" (failed to read system DNS configuration: %v)\n", err)
}
} else if osCfg == nil {
fmt.Println(" (no OS DNS configuration available)")
} else {
fmt.Println("Nameservers:")
if len(osCfg.Nameservers) == 0 {
fmt.Println(" (no nameservers found, DNS queries might fail\nunless the coordination server is providing a nameserver)")
}
for _, ns := range osCfg.Nameservers {
fmt.Printf(" - %v\n", ns)
}
fmt.Print("\n")
fmt.Println("Search domains:")
if len(osCfg.SearchDomains) == 0 {
fmt.Println(" (no search domains found)")
}
for _, sd := range osCfg.SearchDomains {
fmt.Printf(" - %v\n", sd)
}
if all {
fmt.Print("\n")
fmt.Println("Match domains:")
if len(osCfg.MatchDomains) == 0 {
fmt.Println(" (no match domains found)")
}
for _, md := range osCfg.MatchDomains {
fmt.Printf(" - %v\n", md)
}
}
}
fmt.Print("\n")
fmt.Println("[this is a preliminary version of this command; the output format may change in the future]")
return nil
}
func fetchNetMap() (netMap *netmap.NetworkMap, err error) {
w, err := localClient.WatchIPNBus(context.Background(), ipn.NotifyInitialNetMap)
if err != nil {
return nil, err
}
defer w.Close()
notify, err := w.Next()
if err != nil {
return nil, err
}
if notify.NetMap == nil {
return nil, fmt.Errorf("no network map yet available, please try again later")
}
return notify.NetMap, nil
}
func dnsStatusLongHelp() string {
return `The 'tailscale dns status' subcommand prints the current DNS status and configuration, including:
- Whether the built-in DNS forwarder is enabled.
- The MagicDNS configuration provided by the coordination server.
- Details on which resolver(s) Tailscale believes the system is using by default.
The --all flag can be used to output advanced debugging information, including fallback resolvers, nameservers, certificate domains, extra records, and the exit node filtered set.
=== Contents of the MagicDNS configuration ===
The MagicDNS configuration is provided by the coordination server to the client and includes the following components:
- MagicDNS enablement status: Indicates whether MagicDNS is enabled across the entire tailnet.
- MagicDNS Suffix: The DNS suffix used for devices within your tailnet.
- DNS Name: The DNS name that other devices in the tailnet can use to reach this device.
- Resolvers: The preferred DNS resolver(s) to be used for resolving queries, in order of preference. If no resolvers are listed here, the system defaults are used.
- Split DNS Routes: Custom DNS resolvers may be used to resolve hostnames in specific domains, this is also known as a 'Split DNS' configuration. The mapping of domains to their respective resolvers is provided here.
- Certificate Domains: The DNS names for which the coordination server will assist in provisioning TLS certificates.
- Extra Records: Additional DNS records that the coordination server might provide to the internal DNS resolver.
- Exit Node Filtered Set: DNS suffixes that the node, when acting as an exit node DNS proxy, will not answer.
For more information about the DNS functionality built into Tailscale, refer to https://tailscale.com/kb/1054/dns.`
}

View File

@@ -0,0 +1,72 @@
package cli
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
)
func runDNSStream(ctx context.Context, args []string) error {
fmt.Printf(`Privacy warning! To stream DNS queries, this tool will set these Tailscale debug flags, which would normally be disabled by default:
- TS_DEBUG_DNS_FORWARD_SEND=true
- TS_DEBUG_DNS_INCLUDE_NAMES=true
TS_DEBUG_DNS_FORWARD_SEND instructs Tailscale to log DNS queries and responses as they are handled by the internal DNS forwarder.
TS_DEBUG_DNS_INCLUDE_NAMES instructs Tailscale to include queried and resolved DNS hostnames in the logs.
Unless the 'TS_NO_LOGS_NO_SUPPORT' flag was previously set, logs are uploaded to Tailscale for diagnostic and debugging purposes, which can be a concern in privacy-sensitive environments.
If you are concerned about the privacy implications of this, run this tool with the '--no-names' flag, which will avoid logging hostnames.`)
fmt.Printf("\n\n")
fmt.Println("Press Enter to start streaming DNS logs, or Ctrl+C to quit this tool.")
buf := bufio.NewReader(os.Stdin)
_, err := buf.ReadBytes('\n')
if err != nil {
fmt.Println(err)
return nil
}
err = localClient.DebugEnvknob(ctx, "TS_DEBUG_DNS_FORWARD_SEND", "true")
if err != nil {
fmt.Printf("failed to set TS_DEBUG_DNS_FORWARD_SEND=true: %v\n", err)
return nil
}
err = localClient.DebugEnvknob(ctx, "TS_DEBUG_DNS_INCLUDE_NAMES", "true")
if err != nil {
fmt.Printf("failed to set TS_DEBUG_DNS_INCLUDE_NAMES=true: %v\n", err)
return nil
}
logs, err := localClient.TailDaemonLogs(ctx)
if err != nil {
return err
}
fmt.Println("Streaming DNS logs. Press Ctrl+C to stop.")
d := json.NewDecoder(logs)
for {
var line struct {
Text string `json:"text"`
Verbose int `json:"v"`
Time string `json:"client_time"`
}
err := d.Decode(&line)
if err != nil {
return err
}
text := strings.TrimSpace(line.Text)
dnsPrefix := "dns: resolver: forward: "
if !strings.HasPrefix(text, dnsPrefix) {
continue
}
text = strings.TrimPrefix(text, dnsPrefix)
fmt.Println(text)
}
}

54
cmd/tailscale/cli/dns.go Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"flag"
"github.com/peterbourgon/ff/v3/ffcli"
)
var dnsCmd = &ffcli.Command{
Name: "dns",
ShortHelp: "Diagnose the internal DNS forwarder",
LongHelp: dnsCmdLongHelp(),
ShortUsage: "tailscale dns <subcommand> [flags]",
UsageFunc: usageFuncNoDefaultValues,
Subcommands: []*ffcli.Command{
{
Name: "status",
ShortUsage: "tailscale dns status [--all]",
Exec: runDNSStatus,
ShortHelp: "Prints the current DNS status and configuration",
LongHelp: dnsStatusLongHelp(),
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("status")
fs.BoolVar(&dnsStatusArgs.all, "all", false, "outputs advanced debugging information (fallback resolvers, nameservers, cert domains, extra records, and exit node filtered set)")
return fs
})(),
},
{
Name: "query",
ShortUsage: "tailscale dns query <name> [a|aaaa|cname|mx|ns|opt|ptr|srv|txt]",
Exec: runDNSQuery,
ShortHelp: "Perform a DNS query",
LongHelp: "The 'tailscale dns query' subcommand performs a DNS query for the specified name using the internal DNS forwarder (100.100.100.100).\n\nIt also provides information about the resolver(s) used to resolve the query.",
},
{
Name: "stream",
ShortUsage: "tailscale dns stream",
Exec: runDNSStream,
ShortHelp: "Stream DNS queries and responses",
LongHelp: "The 'tailscale dns stream' subcommand streams DNS queries and responses to and from the internal DNS forwarder, which is useful for debugging DNS issues.",
},
// The above work is tracked in https://github.com/tailscale/tailscale/issues/13326
},
}
func dnsCmdLongHelp() string {
return `The 'tailscale dns' subcommand provides tools for diagnosing the internal DNS forwarder (100.100.100.100).
For more information about the DNS functionality built into Tailscale, refer to https://tailscale.com/kb/1054/dns.`
}

View File

@@ -135,7 +135,7 @@ func TestFilterFormatAndSortExitNodes(t *testing.T) {
result := filterFormatAndSortExitNodes(ps, "")
if res := cmp.Diff(result.Countries, want.Countries, cmpopts.IgnoreUnexported(key.NodePublic{})); res != "" {
t.Fatalf(res)
t.Fatal(res)
}
})
@@ -230,7 +230,7 @@ func TestFilterFormatAndSortExitNodes(t *testing.T) {
result := filterFormatAndSortExitNodes(ps, "Pacific")
if res := cmp.Diff(result.Countries, want.Countries, cmpopts.IgnoreUnexported(key.NodePublic{})); res != "" {
t.Fatalf(res)
t.Fatal(res)
}
})
}

View File

@@ -60,10 +60,10 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink
github.com/tailscale/web-client-prebuilt from tailscale.com/client/web
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+
@@ -98,8 +98,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/internal/noiseconn from tailscale.com/cmd/tailscale/cli
tailscale.com/ipn from tailscale.com/client/tailscale+
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/licenses from tailscale.com/client/web+
tailscale.com/metrics from tailscale.com/derp
tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/captivedetection from tailscale.com/net/netcheck
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
tailscale.com/net/dnscache from tailscale.com/control/controlhttp+
@@ -132,13 +133,14 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/tstime from tailscale.com/control/controlhttp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli+
tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/tsweb/varz from tailscale.com/util/usermetric
tailscale.com/types/dnstype from tailscale.com/tailcfg+
tailscale.com/types/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/client/tailscale+
tailscale.com/types/lazy from tailscale.com/util/testenv+
tailscale.com/types/logger from tailscale.com/client/web+
tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/netmap from tailscale.com/ipn+
tailscale.com/types/nettype from tailscale.com/net/netcheck+
tailscale.com/types/opt from tailscale.com/client/tailscale+
tailscale.com/types/persist from tailscale.com/ipn
@@ -173,6 +175,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
tailscale.com/util/usermetric from tailscale.com/health
tailscale.com/util/vizerror from tailscale.com/tailcfg+
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
@@ -194,6 +197,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
W golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
golang.org/x/exp/maps from tailscale.com/cmd/tailscale/cli+
golang.org/x/net/bpf from github.com/mdlayher/netlink+
@@ -283,6 +287,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
io from archive/tar+
io/fs from archive/tar+
io/ioutil from github.com/mitchellh/go-ps+
iter from maps+
log from expvar+
log/internal from log
maps from tailscale.com/clientupdate+
@@ -314,7 +319,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
regexp/syntax from regexp
runtime/debug from github.com/coder/websocket/internal/xsync+
slices from tailscale.com/client/web+
sort from archive/tar+
sort from compress/flate+
strconv from archive/tar+
strings from archive/tar+
sync from archive/tar+
@@ -327,3 +332,4 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
unicode from bytes+
unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+
unique from net/netip

View File

@@ -115,7 +115,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
L 💣 github.com/illarion/gonotify/v2 from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
@@ -139,7 +139,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L github.com/mdlayher/sdnotify from tailscale.com/util/systemd
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+
github.com/miekg/dns from tailscale.com/net/dns/recursive
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
L github.com/pierrec/lz4/v4 from github.com/u-root/uio/uio
@@ -169,6 +169,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+
L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink
github.com/tailscale/peercred from tailscale.com/ipn/ipnauth
github.com/tailscale/web-client-prebuilt from tailscale.com/client/web
W 💣 github.com/tailscale/wf from tailscale.com/wf
@@ -188,7 +189,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
LD github.com/u-root/u-root/pkg/termios from tailscale.com/ssh/tailssh
L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4
L github.com/u-root/uio/uio from github.com/insomniacslk/dhcp/dhcpv4+
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+
@@ -279,7 +279,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store
L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
L tailscale.com/kube from tailscale.com/ipn/store/kubestore
L tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
L tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/licenses from tailscale.com/client/web
tailscale.com/log/filelogger from tailscale.com/logpolicy
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
@@ -343,7 +345,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/tstime from tailscale.com/control/controlclient+
tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/derp+
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+
@@ -386,7 +388,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+
W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag
tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+
tailscale.com/util/osuser from tailscale.com/ipn/localapi+
tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal+
tailscale.com/util/progresstracking from tailscale.com/ipn/localapi
tailscale.com/util/race from tailscale.com/net/dns/resolver
tailscale.com/util/racebuild from tailscale.com/logpolicy
@@ -403,6 +405,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
tailscale.com/util/truncate from tailscale.com/logtail
tailscale.com/util/uniq from tailscale.com/ipn/ipnlocal+
tailscale.com/util/usermetric from tailscale.com/health+
tailscale.com/util/vizerror from tailscale.com/tailcfg+
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
@@ -441,6 +444,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
LD golang.org/x/crypto/ssh from github.com/pkg/sftp+
golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
golang.org/x/exp/maps from tailscale.com/appc+
@@ -530,6 +534,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
io from archive/tar+
io/fs from archive/tar+
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
iter from maps+
log from expvar+
log/internal from log
LD log/syslog from tailscale.com/ssh/tailssh
@@ -565,7 +570,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
runtime/pprof from net/http/pprof+
runtime/trace from net/http/pprof
slices from tailscale.com/appc+
sort from archive/tar+
sort from compress/flate+
strconv from archive/tar+
strings from archive/tar+
sync from archive/tar+
@@ -578,3 +583,4 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
unicode from bytes+
unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+
unique from net/netip

View File

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

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.21
//go:build go1.23
// The tailscaled program is the Tailscale client daemon. It's configured
// and controlled via the tailscale CLI program.
@@ -417,6 +417,10 @@ func run() (err error) {
sys.Set(driveimpl.NewFileSystemForRemote(logf))
if app := envknob.App(); app != "" {
hostinfo.SetApp(app)
}
return startIPNServer(context.Background(), logf, pol.PublicID, sys)
}

View File

@@ -11,6 +11,7 @@
package main
import (
"bytes"
"context"
"errors"
"flag"
@@ -23,12 +24,15 @@ import (
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"tailscale.com/atomicfile"
"tailscale.com/client/tailscale"
"tailscale.com/hostinfo"
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/util/set"
"tailscale.com/version/distro"
@@ -70,6 +74,9 @@ func (rt *localClientRoundTripper) RoundTrip(req *http.Request) (*http.Response,
}
func main() {
var logBuf logBuffer
log.SetOutput(io.MultiWriter(os.Stderr, &logBuf))
if distro.Get() == distro.Gokrazy {
if !hostinfo.IsNATLabGuestVM() {
// "Exiting immediately with status code 0 when the
@@ -80,19 +87,31 @@ func main() {
}
flag.Parse()
debug := false
if distro.Get() == distro.Gokrazy {
nsRx := regexp.MustCompile(`(?m)^nameserver (.*)`)
for t := time.Now(); time.Since(t) < 10*time.Second; time.Sleep(10 * time.Millisecond) {
all, _ := os.ReadFile("/etc/resolv.conf")
if nsRx.Match(all) {
break
cmdLine, _ := os.ReadFile("/proc/cmdline")
explicitNS := false
for _, s := range strings.Fields(string(cmdLine)) {
if ns, ok := strings.CutPrefix(s, "tta.nameserver="); ok {
err := atomicfile.WriteFile("/tmp/resolv.conf", []byte("nameserver "+ns+"\n"), 0644)
log.Printf("Wrote /tmp/resolv.conf: %v", err)
explicitNS = true
continue
}
if v, ok := strings.CutPrefix(s, "tta.debug="); ok {
debug, _ = strconv.ParseBool(v)
continue
}
}
if !explicitNS {
nsRx := regexp.MustCompile(`(?m)^nameserver (.*)`)
for t := time.Now(); time.Since(t) < 10*time.Second; time.Sleep(10 * time.Millisecond) {
all, _ := os.ReadFile("/etc/resolv.conf")
if nsRx.Match(all) {
break
}
}
}
}
logc, err := net.Dial("tcp", "9.9.9.9:124")
if err == nil {
log.SetOutput(logc)
}
log.Printf("Tailscale Test Agent running.")
@@ -122,28 +141,11 @@ func main() {
})
var hs http.Server
hs.Handler = &serveMux
var (
stMu sync.Mutex
newSet = set.Set[net.Conn]{} // conns in StateNew
)
needConnCh := make(chan bool, 1)
hs.ConnState = func(c net.Conn, s http.ConnState) {
stMu.Lock()
defer stMu.Unlock()
oldLen := len(newSet)
switch s {
case http.StateNew:
newSet.Add(c)
default:
newSet.Delete(c)
}
if oldLen != 0 && len(newSet) == 0 {
select {
case needConnCh <- true:
default:
}
}
revSt := revDialState{
needConnCh: make(chan bool, 1),
debug: debug,
}
hs.ConnState = revSt.connState
conns := make(chan net.Conn, 1)
lcRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://local-tailscaled.sock")))
@@ -163,11 +165,17 @@ func main() {
serveCmd(w, "tailscale", "up", "--login-server=http://control.tailscale")
})
ttaMux.HandleFunc("/fw", addFirewallHandler)
ttaMux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
logBuf.mu.Lock()
defer logBuf.mu.Unlock()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(logBuf.buf.Bytes())
})
go hs.Serve(chanListener(conns))
// For doing agent operations locally from gokrazy:
// (e.g. with "wget -O - localhost:8123/fw")
// (e.g. with "wget -O - localhost:8123/fw" or "wget -O - localhost:8123/logs"
// to get early tta logs before the port 124 connection is established)
go func() {
err := http.ListenAndServe("127.0.0.1:8123", &ttaMux)
if err != nil {
@@ -175,26 +183,14 @@ func main() {
}
}()
var lastErr string
needConnCh <- true
for {
<-needConnCh
c, err := connect()
if err != nil {
s := err.Error()
if s != lastErr {
log.Printf("Connect failure: %v", s)
}
lastErr = s
time.Sleep(time.Second)
continue
}
conns <- c
}
revSt.runDialOutLoop(conns)
}
func connect() (net.Conn, error) {
c, err := net.Dial("tcp", *driverAddr)
var d net.Dialer
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
c, err := d.DialContext(ctx, "tcp", *driverAddr)
if err != nil {
return nil, err
}
@@ -222,6 +218,100 @@ func (cl chanListener) Addr() net.Addr {
}
}
type revDialState struct {
needConnCh chan bool
debug bool
mu sync.Mutex
newSet set.Set[net.Conn] // conns in StateNew
onNew map[net.Conn]func()
}
func (s *revDialState) connState(c net.Conn, cs http.ConnState) {
s.mu.Lock()
defer s.mu.Unlock()
oldLen := len(s.newSet)
switch cs {
case http.StateNew:
if f, ok := s.onNew[c]; ok {
f()
delete(s.onNew, c)
}
s.newSet.Make()
s.newSet.Add(c)
default:
s.newSet.Delete(c)
}
s.vlogf("ConnState: %p now %v; newSet %v=>%v", c, s, oldLen, len(s.newSet))
if len(s.newSet) < 2 {
select {
case s.needConnCh <- true:
default:
}
}
}
func (s *revDialState) waitNeedConnect() {
for {
s.mu.Lock()
need := len(s.newSet) < 2
s.mu.Unlock()
if need {
return
}
<-s.needConnCh
}
}
func (s *revDialState) vlogf(format string, arg ...any) {
if !s.debug {
return
}
log.Printf(format, arg...)
}
func (s *revDialState) runDialOutLoop(conns chan<- net.Conn) {
var lastErr string
connected := false
for {
s.vlogf("[dial-driver] waiting need connect...")
s.waitNeedConnect()
s.vlogf("[dial-driver] connecting...")
t0 := time.Now()
c, err := connect()
if err != nil {
s := err.Error()
if s != lastErr {
log.Printf("[dial-driver] connect failure: %v", s)
}
lastErr = s
time.Sleep(time.Second)
continue
}
if !connected {
connected = true
log.Printf("Connected to %v", *driverAddr)
}
s.vlogf("[dial-driver] connected %v => %v after %v", c.LocalAddr(), c.RemoteAddr(), time.Since(t0))
inHTTP := make(chan struct{})
s.mu.Lock()
mak.Set(&s.onNew, c, func() { close(inHTTP) })
s.mu.Unlock()
s.vlogf("[dial-driver] sending...")
conns <- c
s.vlogf("[dial-driver] sent; waiting")
select {
case <-inHTTP:
s.vlogf("[dial-driver] conn in HTTP")
case <-time.After(2 * time.Second):
s.vlogf("[dial-driver] timeout waiting for conn to be accepted into HTTP")
}
}
}
func addFirewallHandler(w http.ResponseWriter, r *http.Request) {
if addFirewall == nil {
http.Error(w, "firewall not supported", 500)
@@ -236,3 +326,24 @@ func addFirewallHandler(w http.ResponseWriter, r *http.Request) {
}
var addFirewall func() error // set by fw_linux.go
// logBuffer is a bytes.Buffer that is safe for concurrent use
// intended to capture early logs from the process, even if
// gokrazy's syslog streaming isn't working or yet working.
// It only captures the first 1MB of logs, as that's considered
// plenty for early debugging. At runtime, it's assumed that
// syslog log streaming is working.
type logBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (lb *logBuffer) Write(p []byte) (n int, err error) {
lb.mu.Lock()
defer lb.mu.Unlock()
const maxSize = 1 << 20 // more than plenty; see type comment
if lb.buf.Len() > maxSize {
return len(p), nil
}
return lb.buf.Write(p)
}

View File

@@ -13,7 +13,7 @@ import (
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers --clone-only-type=OnlyGetClone
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers,StructWithTypeAliasFields,GenericTypeAliasStruct --clone-only-type=OnlyGetClone
type StructWithoutPtrs struct {
Int int
@@ -202,3 +202,34 @@ type StructWithContainers struct {
CloneableMap MapContainer[int, *StructWithPtrs]
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
}
type (
StructWithPtrsAlias = StructWithPtrs
StructWithoutPtrsAlias = StructWithoutPtrs
StructWithPtrsAliasView = StructWithPtrsView
StructWithoutPtrsAliasView = StructWithoutPtrsView
)
type StructWithTypeAliasFields struct {
WithPtr StructWithPtrsAlias
WithoutPtr StructWithoutPtrsAlias
WithPtrByPtr *StructWithPtrsAlias
WithoutPtrByPtr *StructWithoutPtrsAlias
SliceWithPtrs []*StructWithPtrsAlias
SliceWithoutPtrs []*StructWithoutPtrsAlias
MapWithPtrs map[string]*StructWithPtrsAlias
MapWithoutPtrs map[string]*StructWithoutPtrsAlias
MapOfSlicesWithPtrs map[string][]*StructWithPtrsAlias
MapOfSlicesWithoutPtrs map[string][]*StructWithoutPtrsAlias
}
type integer = constraints.Integer
type GenericTypeAliasStruct[T integer, T2 views.ViewCloner[T2, V2], V2 views.StructView[T2]] struct {
NonCloneable T
Cloneable T2
}

View File

@@ -441,3 +441,105 @@ var _StructWithContainersCloneNeedsRegeneration = StructWithContainers(struct {
CloneableMap MapContainer[int, *StructWithPtrs]
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
}{})
// Clone makes a deep copy of StructWithTypeAliasFields.
// The result aliases no memory with the original.
func (src *StructWithTypeAliasFields) Clone() *StructWithTypeAliasFields {
if src == nil {
return nil
}
dst := new(StructWithTypeAliasFields)
*dst = *src
dst.WithPtr = *src.WithPtr.Clone()
dst.WithPtrByPtr = src.WithPtrByPtr.Clone()
if dst.WithoutPtrByPtr != nil {
dst.WithoutPtrByPtr = ptr.To(*src.WithoutPtrByPtr)
}
if src.SliceWithPtrs != nil {
dst.SliceWithPtrs = make([]*StructWithPtrsAlias, len(src.SliceWithPtrs))
for i := range dst.SliceWithPtrs {
if src.SliceWithPtrs[i] == nil {
dst.SliceWithPtrs[i] = nil
} else {
dst.SliceWithPtrs[i] = src.SliceWithPtrs[i].Clone()
}
}
}
if src.SliceWithoutPtrs != nil {
dst.SliceWithoutPtrs = make([]*StructWithoutPtrsAlias, len(src.SliceWithoutPtrs))
for i := range dst.SliceWithoutPtrs {
if src.SliceWithoutPtrs[i] == nil {
dst.SliceWithoutPtrs[i] = nil
} else {
dst.SliceWithoutPtrs[i] = ptr.To(*src.SliceWithoutPtrs[i])
}
}
}
if dst.MapWithPtrs != nil {
dst.MapWithPtrs = map[string]*StructWithPtrsAlias{}
for k, v := range src.MapWithPtrs {
if v == nil {
dst.MapWithPtrs[k] = nil
} else {
dst.MapWithPtrs[k] = v.Clone()
}
}
}
if dst.MapWithoutPtrs != nil {
dst.MapWithoutPtrs = map[string]*StructWithoutPtrsAlias{}
for k, v := range src.MapWithoutPtrs {
if v == nil {
dst.MapWithoutPtrs[k] = nil
} else {
dst.MapWithoutPtrs[k] = ptr.To(*v)
}
}
}
if dst.MapOfSlicesWithPtrs != nil {
dst.MapOfSlicesWithPtrs = map[string][]*StructWithPtrsAlias{}
for k := range src.MapOfSlicesWithPtrs {
dst.MapOfSlicesWithPtrs[k] = append([]*StructWithPtrsAlias{}, src.MapOfSlicesWithPtrs[k]...)
}
}
if dst.MapOfSlicesWithoutPtrs != nil {
dst.MapOfSlicesWithoutPtrs = map[string][]*StructWithoutPtrsAlias{}
for k := range src.MapOfSlicesWithoutPtrs {
dst.MapOfSlicesWithoutPtrs[k] = append([]*StructWithoutPtrsAlias{}, src.MapOfSlicesWithoutPtrs[k]...)
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithTypeAliasFieldsCloneNeedsRegeneration = StructWithTypeAliasFields(struct {
WithPtr StructWithPtrsAlias
WithoutPtr StructWithoutPtrsAlias
WithPtrByPtr *StructWithPtrsAlias
WithoutPtrByPtr *StructWithoutPtrsAlias
SliceWithPtrs []*StructWithPtrsAlias
SliceWithoutPtrs []*StructWithoutPtrsAlias
MapWithPtrs map[string]*StructWithPtrsAlias
MapWithoutPtrs map[string]*StructWithoutPtrsAlias
MapOfSlicesWithPtrs map[string][]*StructWithPtrsAlias
MapOfSlicesWithoutPtrs map[string][]*StructWithoutPtrsAlias
}{})
// Clone makes a deep copy of GenericTypeAliasStruct.
// The result aliases no memory with the original.
func (src *GenericTypeAliasStruct[T, T2, V2]) Clone() *GenericTypeAliasStruct[T, T2, V2] {
if src == nil {
return nil
}
dst := new(GenericTypeAliasStruct[T, T2, V2])
*dst = *src
dst.Cloneable = src.Cloneable.Clone()
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
func _GenericTypeAliasStructCloneNeedsRegeneration[T integer, T2 views.ViewCloner[T2, V2], V2 views.StructView[T2]](GenericTypeAliasStruct[T, T2, V2]) {
_GenericTypeAliasStructCloneNeedsRegeneration(struct {
NonCloneable T
Cloneable T2
}{})
}

View File

@@ -14,7 +14,7 @@ import (
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers,StructWithTypeAliasFields,GenericTypeAliasStruct
// View returns a readonly view of StructWithPtrs.
func (p *StructWithPtrs) View() StructWithPtrsView {
@@ -676,3 +676,164 @@ var _StructWithContainersViewNeedsRegeneration = StructWithContainers(struct {
CloneableMap MapContainer[int, *StructWithPtrs]
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
}{})
// View returns a readonly view of StructWithTypeAliasFields.
func (p *StructWithTypeAliasFields) View() StructWithTypeAliasFieldsView {
return StructWithTypeAliasFieldsView{ж: p}
}
// StructWithTypeAliasFieldsView provides a read-only view over StructWithTypeAliasFields.
//
// Its methods should only be called if `Valid()` returns true.
type StructWithTypeAliasFieldsView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *StructWithTypeAliasFields
}
// Valid reports whether underlying value is non-nil.
func (v StructWithTypeAliasFieldsView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v StructWithTypeAliasFieldsView) AsStruct() *StructWithTypeAliasFields {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v StructWithTypeAliasFieldsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *StructWithTypeAliasFieldsView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x StructWithTypeAliasFields
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v StructWithTypeAliasFieldsView) WithPtr() StructWithPtrsView { return v.ж.WithPtr.View() }
func (v StructWithTypeAliasFieldsView) WithoutPtr() StructWithoutPtrsAlias { return v.ж.WithoutPtr }
func (v StructWithTypeAliasFieldsView) WithPtrByPtr() StructWithPtrsAliasView {
return v.ж.WithPtrByPtr.View()
}
func (v StructWithTypeAliasFieldsView) WithoutPtrByPtr() *StructWithoutPtrsAlias {
if v.ж.WithoutPtrByPtr == nil {
return nil
}
x := *v.ж.WithoutPtrByPtr
return &x
}
func (v StructWithTypeAliasFieldsView) SliceWithPtrs() views.SliceView[*StructWithPtrsAlias, StructWithPtrsAliasView] {
return views.SliceOfViews[*StructWithPtrsAlias, StructWithPtrsAliasView](v.ж.SliceWithPtrs)
}
func (v StructWithTypeAliasFieldsView) SliceWithoutPtrs() views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView] {
return views.SliceOfViews[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView](v.ж.SliceWithoutPtrs)
}
func (v StructWithTypeAliasFieldsView) MapWithPtrs() views.MapFn[string, *StructWithPtrsAlias, StructWithPtrsAliasView] {
return views.MapFnOf(v.ж.MapWithPtrs, func(t *StructWithPtrsAlias) StructWithPtrsAliasView {
return t.View()
})
}
func (v StructWithTypeAliasFieldsView) MapWithoutPtrs() views.MapFn[string, *StructWithoutPtrsAlias, StructWithoutPtrsAliasView] {
return views.MapFnOf(v.ж.MapWithoutPtrs, func(t *StructWithoutPtrsAlias) StructWithoutPtrsAliasView {
return t.View()
})
}
func (v StructWithTypeAliasFieldsView) MapOfSlicesWithPtrs() views.MapFn[string, []*StructWithPtrsAlias, views.SliceView[*StructWithPtrsAlias, StructWithPtrsAliasView]] {
return views.MapFnOf(v.ж.MapOfSlicesWithPtrs, func(t []*StructWithPtrsAlias) views.SliceView[*StructWithPtrsAlias, StructWithPtrsAliasView] {
return views.SliceOfViews[*StructWithPtrsAlias, StructWithPtrsAliasView](t)
})
}
func (v StructWithTypeAliasFieldsView) MapOfSlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrsAlias, views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView]] {
return views.MapFnOf(v.ж.MapOfSlicesWithoutPtrs, func(t []*StructWithoutPtrsAlias) views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView] {
return views.SliceOfViews[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView](t)
})
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithTypeAliasFieldsViewNeedsRegeneration = StructWithTypeAliasFields(struct {
WithPtr StructWithPtrsAlias
WithoutPtr StructWithoutPtrsAlias
WithPtrByPtr *StructWithPtrsAlias
WithoutPtrByPtr *StructWithoutPtrsAlias
SliceWithPtrs []*StructWithPtrsAlias
SliceWithoutPtrs []*StructWithoutPtrsAlias
MapWithPtrs map[string]*StructWithPtrsAlias
MapWithoutPtrs map[string]*StructWithoutPtrsAlias
MapOfSlicesWithPtrs map[string][]*StructWithPtrsAlias
MapOfSlicesWithoutPtrs map[string][]*StructWithoutPtrsAlias
}{})
// View returns a readonly view of GenericTypeAliasStruct.
func (p *GenericTypeAliasStruct[T, T2, V2]) View() GenericTypeAliasStructView[T, T2, V2] {
return GenericTypeAliasStructView[T, T2, V2]{ж: p}
}
// GenericTypeAliasStructView[T, T2, V2] provides a read-only view over GenericTypeAliasStruct[T, T2, V2].
//
// Its methods should only be called if `Valid()` returns true.
type GenericTypeAliasStructView[T integer, T2 views.ViewCloner[T2, V2], V2 views.StructView[T2]] struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *GenericTypeAliasStruct[T, T2, V2]
}
// Valid reports whether underlying value is non-nil.
func (v GenericTypeAliasStructView[T, T2, V2]) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v GenericTypeAliasStructView[T, T2, V2]) AsStruct() *GenericTypeAliasStruct[T, T2, V2] {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v GenericTypeAliasStructView[T, T2, V2]) MarshalJSON() ([]byte, error) {
return json.Marshal(v.ж)
}
func (v *GenericTypeAliasStructView[T, T2, V2]) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x GenericTypeAliasStruct[T, T2, V2]
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v GenericTypeAliasStructView[T, T2, V2]) NonCloneable() T { return v.ж.NonCloneable }
func (v GenericTypeAliasStructView[T, T2, V2]) Cloneable() V2 { return v.ж.Cloneable.View() }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
func _GenericTypeAliasStructViewNeedsRegeneration[T integer, T2 views.ViewCloner[T2, V2], V2 views.StructView[T2]](GenericTypeAliasStruct[T, T2, V2]) {
_GenericTypeAliasStructViewNeedsRegeneration(struct {
NonCloneable T
Cloneable T2
}{})
}

View File

@@ -230,7 +230,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
writeTemplate("sliceField")
}
continue
case *types.Struct, *types.Named:
case *types.Struct:
strucT := underlying
args.FieldType = it.QualifiedName(fieldType)
if codegen.ContainsPointers(strucT) {
@@ -262,7 +262,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
mElem := m.Elem()
var template string
switch u := mElem.(type) {
case *types.Struct, *types.Named:
case *types.Struct, *types.Named, *types.Alias:
strucT := u
args.FieldType = it.QualifiedName(fieldType)
if codegen.ContainsPointers(strucT) {
@@ -281,7 +281,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
slice := u
sElem := slice.Elem()
switch x := sElem.(type) {
case *types.Basic, *types.Named:
case *types.Basic, *types.Named, *types.Alias:
sElem := it.QualifiedName(sElem)
args.MapValueView = fmt.Sprintf("views.Slice[%v]", sElem)
args.MapValueType = sElem
@@ -292,7 +292,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
template = "unsupportedField"
if _, isIface := pElem.Underlying().(*types.Interface); !isIface {
switch pElem.(type) {
case *types.Struct, *types.Named:
case *types.Struct, *types.Named, *types.Alias:
ptrType := it.QualifiedName(ptr)
viewType := appendNameSuffix(it.QualifiedName(pElem), "View")
args.MapFn = fmt.Sprintf("views.SliceOfViews[%v,%v](t)", ptrType, viewType)
@@ -313,7 +313,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
pElem := ptr.Elem()
if _, isIface := pElem.Underlying().(*types.Interface); !isIface {
switch pElem.(type) {
case *types.Struct, *types.Named:
case *types.Struct, *types.Named, *types.Alias:
args.MapValueType = it.QualifiedName(ptr)
args.MapValueView = appendNameSuffix(it.QualifiedName(pElem), "View")
args.MapFn = "t.View()"
@@ -422,7 +422,7 @@ func viewTypeForValueType(typ types.Type) types.Type {
func viewTypeForContainerType(typ types.Type) (*types.Named, *types.Func) {
// The container type should be an instantiated generic type,
// with its first type parameter specifying the element type.
containerType, ok := typ.(*types.Named)
containerType, ok := codegen.NamedTypeOf(typ)
if !ok || containerType.TypeArgs().Len() == 0 {
return nil, nil
}
@@ -435,7 +435,7 @@ func viewTypeForContainerType(typ types.Type) (*types.Named, *types.Func) {
if !ok {
return nil, nil
}
containerViewGenericType, ok := containerViewTypeObj.Type().(*types.Named)
containerViewGenericType, ok := codegen.NamedTypeOf(containerViewTypeObj.Type())
if !ok || containerViewGenericType.TypeParams().Len() != containerType.TypeArgs().Len()+1 {
return nil, nil
}
@@ -448,7 +448,7 @@ func viewTypeForContainerType(typ types.Type) (*types.Named, *types.Func) {
}
// ...and add the element view type.
// For that, we need to first determine the named elem type...
elemType, ok := baseType(containerType.TypeArgs().At(containerType.TypeArgs().Len() - 1)).(*types.Named)
elemType, ok := codegen.NamedTypeOf(baseType(containerType.TypeArgs().At(containerType.TypeArgs().Len() - 1)))
if !ok {
return nil, nil
}
@@ -473,7 +473,7 @@ func viewTypeForContainerType(typ types.Type) (*types.Named, *types.Func) {
}
// If elemType is an instantiated generic type, instantiate the elemViewType as well.
if elemTypeArgs := elemType.TypeArgs(); elemTypeArgs != nil {
elemViewType = must.Get(types.Instantiate(nil, elemViewType, collectTypes(elemTypeArgs), false)).(*types.Named)
elemViewType, _ = codegen.NamedTypeOf(must.Get(types.Instantiate(nil, elemViewType, collectTypes(elemTypeArgs), false)))
}
// And finally set the elemViewType as the last type argument.
containerViewTypeArgs[len(containerViewTypeArgs)-1] = elemViewType
@@ -567,7 +567,7 @@ func main() {
if cloneOnlyType[typeName] {
continue
}
typ, ok := namedTypes[typeName]
typ, ok := namedTypes[typeName].(*types.Named)
if !ok {
log.Fatalf("could not find type %s", typeName)
}

View File

@@ -2,12 +2,18 @@
echo "Type 'C-a c' to enter monitor; q to quit."
# If the USE_V6 environment is set to 1, set the nameserver explicitly to.
EXTRA_ARG=""
if [ "$USE_V6" = "1" ]; then
EXTRA_ARG="tta.nameserver=2411::411"
fi
set -eux
qemu-system-x86_64 -M microvm,isa-serial=off \
-m 1G \
-nodefaults -no-user-config -nographic \
-kernel $HOME/src/github.com/tailscale/gokrazy-kernel/vmlinuz \
-append "console=hvc0 root=PARTUUID=60c24cc1-f3f9-427a-8199-76baa2d60001/PARTNROFF=1 ro init=/gokrazy/init panic=10 oops=panic pci=off nousb tsc=unstable clocksource=hpet tailscale-tta=1 tailscaled.env=TS_DEBUG_RAW_DISCO=1" \
-append "console=hvc0 root=PARTUUID=60c24cc1-f3f9-427a-8199-76baa2d60001/PARTNROFF=1 ro init=/gokrazy/init panic=10 oops=panic pci=off nousb tsc=unstable clocksource=hpet tailscale-tta=1 tailscaled.env=TS_DEBUG_RAW_DISCO=1 ${EXTRA_ARG}" \
-drive id=blk0,file=$HOME/src/tailscale.com/gokrazy/natlabapp.img,format=raw \
-device virtio-blk-device,drive=blk0 \
-device virtio-rng-device \

View File

@@ -22,11 +22,15 @@ import (
)
var (
listen = flag.String("listen", "/tmp/qemu.sock", "path to listen on")
nat = flag.String("nat", "easy", "type of NAT to use")
nat2 = flag.String("nat2", "hard", "type of NAT to use for second network")
portmap = flag.Bool("portmap", false, "enable portmapping")
dgram = flag.Bool("dgram", false, "enable datagram mode; for use with macOS Hypervisor.Framework and VZFileHandleNetworkDeviceAttachment")
listen = flag.String("listen", "/tmp/qemu.sock", "path to listen on")
nat = flag.String("nat", "easy", "type of NAT to use")
nat2 = flag.String("nat2", "hard", "type of NAT to use for second network")
portmap = flag.Bool("portmap", false, "enable portmapping; requires --v4")
dgram = flag.Bool("dgram", false, "enable datagram mode; for use with macOS Hypervisor.Framework and VZFileHandleNetworkDeviceAttachment")
blend = flag.Bool("blend", true, "blend reality (controlplane.tailscale.com and DERPs) into the virtual network")
pcapFile = flag.String("pcap", "", "if non-empty, filename to write pcap")
v4 = flag.Bool("v4", true, "enable IPv4")
v6 = flag.Bool("v6", true, "enable IPv6")
)
func main() {
@@ -57,9 +61,20 @@ func main() {
}
var c vnet.Config
node1 := c.AddNode(c.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.NAT(*nat)))
c.SetPCAPFile(*pcapFile)
c.SetBlendReality(*blend)
var net1opt = []any{vnet.NAT(*nat)}
if *v4 {
net1opt = append(net1opt, "2.1.1.1", "192.168.1.1/24")
}
if *v6 {
net1opt = append(net1opt, "2000:52::1/64")
}
node1 := c.AddNode(c.AddNetwork(net1opt...))
c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", vnet.NAT(*nat2)))
if *portmap {
if *portmap && *v4 {
node1.Network().AddService(vnet.NATPMP)
}
@@ -68,8 +83,10 @@ func main() {
log.Fatalf("newServer: %v", err)
}
if err := s.PopulateDERPMapIPs(); err != nil {
log.Printf("warning: ignoring failure to populate DERP map: %v", err)
if *blend {
if err := s.PopulateDERPMapIPs(); err != nil {
log.Printf("warning: ignoring failure to populate DERP map: %v", err)
}
}
s.WriteStartingBanner(os.Stdout)
@@ -85,6 +102,7 @@ func main() {
http.ListenAndServe(":8080", rp)
}()
go func() {
var last string
getStatus := func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -93,7 +111,10 @@ func main() {
log.Printf("NodeStatus: %v", err)
return
}
log.Printf("NodeStatus: %v", logger.AsJSON(st))
if st.BackendState != last {
last = st.BackendState
log.Printf("NodeStatus: %v", logger.AsJSON(st))
}
}
for {
time.Sleep(5 * time.Second)

View File

@@ -19,7 +19,6 @@ import (
"sync"
"time"
xmaps "golang.org/x/exp/maps"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/tailcfg"
@@ -313,10 +312,8 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
}
}
if packetFilterChanged {
keys := xmaps.Keys(ms.namedPacketFilters)
sort.Strings(keys)
var concat []tailcfg.FilterRule
for _, v := range keys {
for _, v := range slices.Sorted(maps.Keys(ms.namedPacketFilters)) {
concat = ms.namedPacketFilters[v].AppendTo(concat)
}
ms.lastPacketFilterRules = views.SliceOf(concat)

View File

@@ -38,7 +38,7 @@ var getMachineCertificateSubjectOnce struct {
// Example: "CN=Tailscale Inc Test Root CA,OU=Tailscale Inc Test Certificate Authority,O=Tailscale Inc,ST=ON,C=CA"
func getMachineCertificateSubject() string {
getMachineCertificateSubjectOnce.Do(func() {
getMachineCertificateSubjectOnce.v, _ = syspolicy.GetString("MachineCertificateSubject", "")
getMachineCertificateSubjectOnce.v, _ = syspolicy.GetString(syspolicy.MachineCertificateSubject, "")
})
return getMachineCertificateSubjectOnce.v

View File

@@ -121,6 +121,8 @@ func newClient(privateKey key.NodePrivate, nc Conn, brw *bufio.ReadWriter, logf
return c, nil
}
func (c *Client) PublicKey() key.NodePublic { return c.publicKey }
func (c *Client) recvServerKey() error {
var buf [40]byte
t, flen, err := readFrame(c.br, 1<<10, buf[:])

View File

@@ -46,7 +46,9 @@ import (
"tailscale.com/tstime/rate"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
"tailscale.com/util/set"
"tailscale.com/util/slicesx"
"tailscale.com/version"
)
@@ -155,7 +157,7 @@ type Server struct {
mu sync.Mutex
closed bool
netConns map[Conn]chan struct{} // chan is closed when conn closes
clients map[key.NodePublic]clientSet
clients map[key.NodePublic]*clientSet
watchers set.Set[*sclient] // mesh peers
// clientsMesh tracks all clients in the cluster, both locally
// and to mesh peers. If the value is nil, that means the
@@ -163,11 +165,11 @@ type Server struct {
// remote). If the value is non-nil, it's remote (+ maybe also
// local).
clientsMesh map[key.NodePublic]PacketForwarder
// sentTo tracks which peers have sent to which other peers,
// and at which connection number. This isn't on sclient
// because it includes intra-region forwarded packets as the
// src.
sentTo map[key.NodePublic]map[key.NodePublic]int64 // src => dst => dst's latest sclient.connNum
// peerGoneWatchers is the set of watchers that subscribed to a
// peer disconnecting from the region overall. When a peer
// is gone from the region, we notify all of these watchers,
// calling their funcs in a new goroutine.
peerGoneWatchers map[key.NodePublic]set.HandleSet[func(key.NodePublic)]
// maps from netip.AddrPort to a client's public key
keyOfAddr map[netip.AddrPort]key.NodePublic
@@ -177,8 +179,6 @@ type Server struct {
// clientSet represents 1 or more *sclients.
//
// The two implementations are singleClient and *dupClientSet.
//
// In the common case, client should only have one connection to the
// DERP server for a given key. When they're connected multiple times,
// we record their set of connections in dupClientSet and keep their
@@ -194,26 +194,49 @@ type Server struct {
// "health_error" frame to them that'll communicate to the end users
// that they cloned a device key, and we'll also surface it in the
// admin panel, etc.
type clientSet interface {
// ActiveClient returns the most recently added client to
// the set, as long as it hasn't been disabled, in which
// case it returns nil.
ActiveClient() *sclient
type clientSet struct {
// activeClient holds the currently active connection for the set. It's nil
// if there are no connections or the connection is disabled.
//
// A pointer to a clientSet can be held by peers for long periods of time
// without holding Server.mu to avoid mutex contention on Server.mu, only
// re-acquiring the mutex and checking the clients map if activeClient is
// nil.
activeClient atomic.Pointer[sclient]
// Len returns the number of clients in the set.
Len() int
// ForeachClient calls f for each client in the set.
ForeachClient(f func(*sclient))
// dup is non-nil if there are multiple connections for the
// public key. It's nil in the common case of only one
// client being connected.
//
// dup is guarded by Server.mu.
dup *dupClientSet
}
// singleClient is a clientSet of a single connection.
// This is the common case.
type singleClient struct{ c *sclient }
// Len returns the number of clients in s, which can be
// 0, 1 (the common case), or more (for buggy or transiently
// reconnecting clients).
func (s *clientSet) Len() int {
if s.dup != nil {
return len(s.dup.set)
}
if s.activeClient.Load() != nil {
return 1
}
return 0
}
func (s singleClient) ActiveClient() *sclient { return s.c }
func (s singleClient) Len() int { return 1 }
func (s singleClient) ForeachClient(f func(*sclient)) { f(s.c) }
// ForeachClient calls f for each client in the set.
//
// The Server.mu must be held.
func (s *clientSet) ForeachClient(f func(*sclient)) {
if s.dup != nil {
for c := range s.dup.set {
f(c)
}
} else if c := s.activeClient.Load(); c != nil {
f(c)
}
}
// A dupClientSet is a clientSet of more than 1 connection.
//
@@ -224,11 +247,12 @@ func (s singleClient) ForeachClient(f func(*sclient)) { f(s.c) }
//
// All fields are guarded by Server.mu.
type dupClientSet struct {
// set is the set of connected clients for sclient.key.
// set is the set of connected clients for sclient.key,
// including the clientSet's active one.
set set.Set[*sclient]
// last is the most recent addition to set, or nil if the most
// recent one has since disconnected and nobody else has send
// recent one has since disconnected and nobody else has sent
// data since.
last *sclient
@@ -239,18 +263,16 @@ type dupClientSet struct {
sendHistory []*sclient
}
func (s *dupClientSet) ActiveClient() *sclient {
if s.last != nil && !s.last.isDisabled.Load() {
return s.last
func (s *clientSet) pickActiveClient() *sclient {
d := s.dup
if d == nil {
return s.activeClient.Load()
}
if d.last != nil && !d.last.isDisabled.Load() {
return d.last
}
return nil
}
func (s *dupClientSet) Len() int { return len(s.set) }
func (s *dupClientSet) ForeachClient(f func(*sclient)) {
for c := range s.set {
f(c)
}
}
// removeClient removes c from s and reports whether it was in s
// to begin with.
@@ -317,12 +339,12 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
packetsRecvByKind: metrics.LabelMap{Label: "kind"},
packetsDroppedReason: metrics.LabelMap{Label: "reason"},
packetsDroppedType: metrics.LabelMap{Label: "type"},
clients: map[key.NodePublic]clientSet{},
clients: map[key.NodePublic]*clientSet{},
clientsMesh: map[key.NodePublic]PacketForwarder{},
netConns: map[Conn]chan struct{}{},
memSys0: ms.Sys,
watchers: set.Set[*sclient]{},
sentTo: map[key.NodePublic]map[key.NodePublic]int64{},
peerGoneWatchers: map[key.NodePublic]set.HandleSet[func(key.NodePublic)]{},
avgQueueDuration: new(uint64),
tcpRtt: metrics.LabelMap{Label: "le"},
meshUpdateBatchSize: metrics.NewHistogram([]float64{0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000}),
@@ -444,7 +466,7 @@ func (s *Server) IsClientConnectedForTest(k key.NodePublic) bool {
if !ok {
return false
}
return x.ActiveClient() != nil
return x.activeClient.Load() != nil
}
// Accept adds a new connection to the server and serves it.
@@ -534,37 +556,43 @@ func (s *Server) registerClient(c *sclient) {
s.mu.Lock()
defer s.mu.Unlock()
curSet := s.clients[c.key]
switch curSet := curSet.(type) {
case nil:
s.clients[c.key] = singleClient{c}
cs, ok := s.clients[c.key]
if !ok {
c.debugLogf("register single client")
case singleClient:
cs = &clientSet{}
s.clients[c.key] = cs
}
was := cs.activeClient.Load()
if was == nil {
// Common case.
} else {
was.isDup.Store(true)
c.isDup.Store(true)
}
dup := cs.dup
if dup == nil && was != nil {
s.dupClientKeys.Add(1)
s.dupClientConns.Add(2) // both old and new count
s.dupClientConnTotal.Add(1)
old := curSet.ActiveClient()
old.isDup.Store(true)
c.isDup.Store(true)
s.clients[c.key] = &dupClientSet{
last: c,
set: set.Set[*sclient]{
old: struct{}{},
c: struct{}{},
},
sendHistory: []*sclient{old},
dup = &dupClientSet{
set: set.Of(c, was),
last: c,
sendHistory: []*sclient{was},
}
cs.dup = dup
c.debugLogf("register duplicate client")
case *dupClientSet:
} else if dup != nil {
s.dupClientConns.Add(1) // the gauge
s.dupClientConnTotal.Add(1) // the counter
c.isDup.Store(true)
curSet.set.Add(c)
curSet.last = c
curSet.sendHistory = append(curSet.sendHistory, c)
dup.set.Add(c)
dup.last = c
dup.sendHistory = append(dup.sendHistory, c)
c.debugLogf("register another duplicate client")
}
cs.activeClient.Store(c)
if _, ok := s.clientsMesh[c.key]; !ok {
s.clientsMesh[c.key] = nil // just for varz of total users in cluster
}
@@ -595,30 +623,47 @@ func (s *Server) unregisterClient(c *sclient) {
s.mu.Lock()
defer s.mu.Unlock()
set := s.clients[c.key]
switch set := set.(type) {
case nil:
set, ok := s.clients[c.key]
if !ok {
c.logf("[unexpected]; clients map is empty")
case singleClient:
return
}
dup := set.dup
if dup == nil {
// The common case.
cur := set.activeClient.Load()
if cur == nil {
c.logf("[unexpected]; active client is nil")
return
}
if cur != c {
c.logf("[unexpected]; active client is not c")
return
}
c.debugLogf("removed connection")
set.activeClient.Store(nil)
delete(s.clients, c.key)
if v, ok := s.clientsMesh[c.key]; ok && v == nil {
delete(s.clientsMesh, c.key)
s.notePeerGoneFromRegionLocked(c.key)
}
s.broadcastPeerStateChangeLocked(c.key, netip.AddrPort{}, 0, false)
case *dupClientSet:
} else {
c.debugLogf("removed duplicate client")
if set.removeClient(c) {
if dup.removeClient(c) {
s.dupClientConns.Add(-1)
} else {
c.logf("[unexpected]; dup client set didn't shrink")
}
if set.Len() == 1 {
if dup.set.Len() == 1 {
// If we drop down to one connection, demote it down
// to a regular single client (a nil dup set).
set.dup = nil
s.dupClientConns.Add(-1) // again; for the original one's
s.dupClientKeys.Add(-1)
var remain *sclient
for remain = range set.set {
for remain = range dup.set {
break
}
if remain == nil {
@@ -626,7 +671,10 @@ func (s *Server) unregisterClient(c *sclient) {
}
remain.isDisabled.Store(false)
remain.isDup.Store(false)
s.clients[c.key] = singleClient{remain}
set.activeClient.Store(remain)
} else {
// Still a duplicate. Pick a winner.
set.activeClient.Store(set.pickActiveClient())
}
}
@@ -642,6 +690,40 @@ func (s *Server) unregisterClient(c *sclient) {
}
}
// addPeerGoneFromRegionWatcher adds a function to be called when peer is gone
// from the region overall. It returns a handle that can be used to remove the
// watcher later.
//
// The provided f func is usually [sclient.onPeerGoneFromRegion], added by
// [sclient.noteSendFromSrc]; this func doesn't take a whole *sclient to make it
// clear what has access to what.
func (s *Server) addPeerGoneFromRegionWatcher(peer key.NodePublic, f func(key.NodePublic)) set.Handle {
s.mu.Lock()
defer s.mu.Unlock()
hset, ok := s.peerGoneWatchers[peer]
if !ok {
hset = set.HandleSet[func(key.NodePublic)]{}
s.peerGoneWatchers[peer] = hset
}
return hset.Add(f)
}
// removePeerGoneFromRegionWatcher removes a peer watcher previously added by
// addPeerGoneFromRegionWatcher, using the handle returned by
// addPeerGoneFromRegionWatcher.
func (s *Server) removePeerGoneFromRegionWatcher(peer key.NodePublic, h set.Handle) {
s.mu.Lock()
defer s.mu.Unlock()
hset, ok := s.peerGoneWatchers[peer]
if !ok {
return
}
delete(hset, h)
if len(hset) == 0 {
delete(s.peerGoneWatchers, peer)
}
}
// notePeerGoneFromRegionLocked sends peerGone frames to parties that
// key has sent to previously (whether those sends were from a local
// client or forwarded). It must only be called after the key has
@@ -655,18 +737,11 @@ func (s *Server) notePeerGoneFromRegionLocked(key key.NodePublic) {
// so they can drop their route entries to us (issue 150)
// or move them over to the active client (in case a replaced client
// connection is being unregistered).
for pubKey, connNum := range s.sentTo[key] {
set, ok := s.clients[pubKey]
if !ok {
continue
}
set.ForeachClient(func(peer *sclient) {
if peer.connNum == connNum {
go peer.requestPeerGoneWrite(key, PeerGoneReasonDisconnected)
}
})
set := s.peerGoneWatchers[key]
for _, f := range set {
go f(key)
}
delete(s.sentTo, key)
delete(s.peerGoneWatchers, key)
}
// requestPeerGoneWriteLimited sends a request to write a "peer gone"
@@ -697,7 +772,7 @@ func (s *Server) addWatcher(c *sclient) {
// Queue messages for each already-connected client.
for peer, clientSet := range s.clients {
ac := clientSet.ActiveClient()
ac := clientSet.activeClient.Load()
if ac == nil {
continue
}
@@ -955,10 +1030,7 @@ func (c *sclient) handleFrameForwardPacket(ft frameType, fl uint32) error {
s.mu.Lock()
if set, ok := s.clients[dstKey]; ok {
dstLen = set.Len()
dst = set.ActiveClient()
}
if dst != nil {
s.notePeerSendLocked(srcKey, dst)
dst = set.activeClient.Load()
}
s.mu.Unlock()
@@ -982,18 +1054,6 @@ func (c *sclient) handleFrameForwardPacket(ft frameType, fl uint32) error {
})
}
// notePeerSendLocked records that src sent to dst. We keep track of
// that so when src disconnects, we can tell dst (if it's still
// around) that src is gone (a peerGone frame).
func (s *Server) notePeerSendLocked(src key.NodePublic, dst *sclient) {
m, ok := s.sentTo[src]
if !ok {
m = map[key.NodePublic]int64{}
s.sentTo[src] = m
}
m[dst.key] = dst.connNum
}
// handleFrameSendPacket reads a "send packet" frame from the client.
func (c *sclient) handleFrameSendPacket(ft frameType, fl uint32) error {
s := c.s
@@ -1010,11 +1070,9 @@ func (c *sclient) handleFrameSendPacket(ft frameType, fl uint32) error {
s.mu.Lock()
if set, ok := s.clients[dstKey]; ok {
dstLen = set.Len()
dst = set.ActiveClient()
dst = set.activeClient.Load()
}
if dst != nil {
s.notePeerSendLocked(c.key, dst)
} else if dstLen < 1 {
if dst == nil && dstLen < 1 {
fwd = s.clientsMesh[dstKey]
}
s.mu.Unlock()
@@ -1134,6 +1192,13 @@ func (c *sclient) sendPkt(dst *sclient, p pkt) error {
return nil
}
// onPeerGoneFromRegion is the callback registered with the Server to be
// notified (in a new goroutine) whenever a peer has disconnected from all DERP
// nodes in the current region.
func (c *sclient) onPeerGoneFromRegion(peer key.NodePublic) {
c.requestPeerGoneWrite(peer, PeerGoneReasonDisconnected)
}
// requestPeerGoneWrite sends a request to write a "peer gone" frame
// with an explanation of why it is gone. It blocks until either the
// write request is scheduled, or the client has closed.
@@ -1256,22 +1321,28 @@ func (s *Server) noteClientActivity(c *sclient) {
s.mu.Lock()
defer s.mu.Unlock()
ds, ok := s.clients[c.key].(*dupClientSet)
cs, ok := s.clients[c.key]
if !ok {
return
}
dup := cs.dup
if dup == nil {
// It became unduped in between the isDup fast path check above
// and the mutex check. Nothing to do.
return
}
if s.dupPolicy == lastWriterIsActive {
ds.last = c
} else if ds.last == nil {
dup.last = c
cs.activeClient.Store(c)
} else if dup.last == nil {
// If we didn't have a primary, let the current
// speaker be the primary.
ds.last = c
dup.last = c
cs.activeClient.Store(c)
}
if sh := ds.sendHistory; len(sh) != 0 && sh[len(sh)-1] == c {
if slicesx.LastEqual(dup.sendHistory, c) {
// The client c was the last client to make activity
// in this set and it was already recorded. Nothing to
// do.
@@ -1281,10 +1352,13 @@ func (s *Server) noteClientActivity(c *sclient) {
// If we saw this connection send previously, then consider
// the group fighting and disable them all.
if s.dupPolicy == disableFighters {
for _, prior := range ds.sendHistory {
for _, prior := range dup.sendHistory {
if prior == c {
ds.ForeachClient(func(c *sclient) {
cs.ForeachClient(func(c *sclient) {
c.isDisabled.Store(true)
if cs.activeClient.Load() == c {
cs.activeClient.Store(nil)
}
})
break
}
@@ -1292,7 +1366,7 @@ func (s *Server) noteClientActivity(c *sclient) {
}
// Append this client to the list of clients who spoke last.
ds.sendHistory = append(ds.sendHistory, c)
dup.sendHistory = append(dup.sendHistory, c)
}
type serverInfo struct {
@@ -1407,6 +1481,11 @@ func (s *Server) recvForwardPacket(br *bufio.Reader, frameLen uint32) (srcKey, d
// sclient is a client connection to the server.
//
// A node (a wireguard public key) can be connected multiple times to a DERP server
// and thus have multiple sclient instances. An sclient represents
// only one of these possibly multiple connections. See clientSet for the
// type that represents the set of all connections for a given key.
//
// (The "s" prefix is to more explicitly distinguish it from Client in derp_client.go)
type sclient struct {
// Static after construction.
@@ -1433,8 +1512,9 @@ type sclient struct {
connectedAt time.Time
preferred bool
// Owned by sender, not thread-safe.
bw *lazyBufioWriter
// Owned by sendLoop, not thread-safe.
sawSrc map[key.NodePublic]set.Handle
bw *lazyBufioWriter
// Guarded by s.mu
//
@@ -1537,24 +1617,36 @@ func (c *sclient) recordQueueTime(enqueuedAt time.Time) {
}
}
func (c *sclient) sendLoop(ctx context.Context) error {
defer func() {
// If the sender shuts down unilaterally due to an error, close so
// that the receive loop unblocks and cleans up the rest.
c.nc.Close()
// onSendLoopDone is called when the send loop is done
// to clean up.
//
// It must only be called from the sendLoop goroutine.
func (c *sclient) onSendLoopDone() {
// If the sender shuts down unilaterally due to an error, close so
// that the receive loop unblocks and cleans up the rest.
c.nc.Close()
// Drain the send queue to count dropped packets
for {
select {
case pkt := <-c.sendQueue:
c.s.recordDrop(pkt.bs, pkt.src, c.key, dropReasonGoneDisconnected)
case pkt := <-c.discoSendQueue:
c.s.recordDrop(pkt.bs, pkt.src, c.key, dropReasonGoneDisconnected)
default:
return
}
// Clean up watches.
for peer, h := range c.sawSrc {
c.s.removePeerGoneFromRegionWatcher(peer, h)
}
// Drain the send queue to count dropped packets
for {
select {
case pkt := <-c.sendQueue:
c.s.recordDrop(pkt.bs, pkt.src, c.key, dropReasonGoneDisconnected)
case pkt := <-c.discoSendQueue:
c.s.recordDrop(pkt.bs, pkt.src, c.key, dropReasonGoneDisconnected)
default:
return
}
}()
}
}
func (c *sclient) sendLoop(ctx context.Context) error {
defer c.onSendLoopDone()
jitter := rand.N(5 * time.Second)
keepAliveTick, keepAliveTickChannel := c.s.clock.NewTicker(keepAlive + jitter)
@@ -1750,6 +1842,7 @@ func (c *sclient) sendPacket(srcKey key.NodePublic, contents []byte) (err error)
pktLen := len(contents)
if withKey {
pktLen += key.NodePublicRawLen
c.noteSendFromSrc(srcKey)
}
if err = writeFrameHeader(c.bw.bw(), frameRecvPacket, uint32(pktLen)); err != nil {
return err
@@ -1763,6 +1856,18 @@ func (c *sclient) sendPacket(srcKey key.NodePublic, contents []byte) (err error)
return err
}
// noteSendFromSrc notes that we are about to write a packet
// from src to sclient.
//
// It must only be called from the sendLoop goroutine.
func (c *sclient) noteSendFromSrc(src key.NodePublic) {
if _, ok := c.sawSrc[src]; ok {
return
}
h := c.s.addPeerGoneFromRegionWatcher(src, c.onPeerGoneFromRegion)
mak.Set(&c.sawSrc, src, h)
}
// AddPacketForwarder registers fwd as a packet forwarder for dst.
// fwd must be comparable.
func (s *Server) AddPacketForwarder(dst key.NodePublic, fwd PacketForwarder) {

View File

@@ -731,7 +731,7 @@ func pubAll(b byte) (ret key.NodePublic) {
func TestForwarderRegistration(t *testing.T) {
s := &Server{
clients: make(map[key.NodePublic]clientSet),
clients: make(map[key.NodePublic]*clientSet),
clientsMesh: map[key.NodePublic]PacketForwarder{},
}
want := func(want map[key.NodePublic]PacketForwarder) {
@@ -746,6 +746,11 @@ func TestForwarderRegistration(t *testing.T) {
t.Errorf("counter = %v; want %v", got, want)
}
}
singleClient := func(c *sclient) *clientSet {
cs := &clientSet{}
cs.activeClient.Store(c)
return cs
}
u1 := pubAll(1)
u2 := pubAll(2)
@@ -808,7 +813,7 @@ func TestForwarderRegistration(t *testing.T) {
key: u1,
logf: logger.Discard,
}
s.clients[u1] = singleClient{u1c}
s.clients[u1] = singleClient(u1c)
s.RemovePacketForwarder(u1, testFwd(100))
want(map[key.NodePublic]PacketForwarder{
u1: nil,
@@ -828,7 +833,7 @@ func TestForwarderRegistration(t *testing.T) {
// Now pretend u1 was already connected locally (so clientsMesh[u1] is nil), and then we heard
// that they're also connected to a peer of ours. That shouldn't transition the forwarder
// from nil to the new one, not a multiForwarder.
s.clients[u1] = singleClient{u1c}
s.clients[u1] = singleClient(u1c)
s.clientsMesh[u1] = nil
want(map[key.NodePublic]PacketForwarder{
u1: nil,
@@ -860,7 +865,7 @@ func TestMultiForwarder(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
s := &Server{
clients: make(map[key.NodePublic]clientSet),
clients: make(map[key.NodePublic]*clientSet),
clientsMesh: map[key.NodePublic]PacketForwarder{},
}
u := pubAll(1)
@@ -1078,43 +1083,48 @@ func TestServerDupClients(t *testing.T) {
}
wantSingleClient := func(t *testing.T, want *sclient) {
t.Helper()
switch s := s.clients[want.key].(type) {
case singleClient:
if s.c != want {
t.Error("wrong single client")
return
}
if want.isDup.Load() {
got, ok := s.clients[want.key]
if !ok {
t.Error("no clients for key")
return
}
if got.dup != nil {
t.Errorf("unexpected dup set for single client")
}
cur := got.activeClient.Load()
if cur != want {
t.Errorf("active client = %q; want %q", clientName[cur], clientName[want])
}
if cur != nil {
if cur.isDup.Load() {
t.Errorf("unexpected isDup on singleClient")
}
if want.isDisabled.Load() {
if cur.isDisabled.Load() {
t.Errorf("unexpected isDisabled on singleClient")
}
case nil:
t.Error("no clients for key")
case *dupClientSet:
t.Error("unexpected multiple clients for key")
}
}
wantNoClient := func(t *testing.T) {
t.Helper()
switch s := s.clients[clientPub].(type) {
case nil:
// Good.
_, ok := s.clients[clientPub]
if !ok {
// Good
return
default:
t.Errorf("got %T; want empty", s)
}
t.Errorf("got client; want empty")
}
wantDupSet := func(t *testing.T) *dupClientSet {
t.Helper()
switch s := s.clients[clientPub].(type) {
case *dupClientSet:
return s
default:
t.Fatalf("wanted dup set; got %T", s)
cs, ok := s.clients[clientPub]
if !ok {
t.Fatal("no set for key; want dup set")
return nil
}
if cs.dup != nil {
return cs.dup
}
t.Fatalf("no dup set for key; want dup set")
return nil
}
wantActive := func(t *testing.T, want *sclient) {
t.Helper()
@@ -1123,7 +1133,7 @@ func TestServerDupClients(t *testing.T) {
t.Error("no set for key")
return
}
got := set.ActiveClient()
got := set.activeClient.Load()
if got != want {
t.Errorf("active client = %q; want %q", clientName[got], clientName[want])
}
@@ -1301,6 +1311,72 @@ func TestLimiter(t *testing.T) {
}
}
// BenchmarkConcurrentStreams exercises mutex contention on a
// single Server instance with multiple concurrent client flows.
func BenchmarkConcurrentStreams(b *testing.B) {
serverPrivateKey := key.NewNode()
s := NewServer(serverPrivateKey, logger.Discard)
defer s.Close()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatal(err)
}
defer ln.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for ctx.Err() == nil {
connIn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
b.Error(err)
return
}
brwServer := bufio.NewReadWriter(bufio.NewReader(connIn), bufio.NewWriter(connIn))
go s.Accept(ctx, connIn, brwServer, "test-client")
}
}()
newClient := func(t testing.TB) *Client {
t.Helper()
connOut, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
b.Fatal(err)
}
t.Cleanup(func() { connOut.Close() })
k := key.NewNode()
brw := bufio.NewReadWriter(bufio.NewReader(connOut), bufio.NewWriter(connOut))
client, err := NewClient(k, connOut, brw, logger.Discard)
if err != nil {
b.Fatalf("client: %v", err)
}
return client
}
b.RunParallel(func(pb *testing.PB) {
c1, c2 := newClient(b), newClient(b)
const packetSize = 100
msg := make([]byte, packetSize)
for pb.Next() {
if err := c1.Send(c2.PublicKey(), msg); err != nil {
b.Fatal(err)
}
_, err := c2.Recv()
if err != nil {
return
}
}
})
}
func BenchmarkSendRecv(b *testing.B) {
for _, size := range []int{10, 100, 1000, 10000} {
b.Run(fmt.Sprintf("msgsize=%d", size), func(b *testing.B) { benchmarkSendRecvSize(b, size) })

View File

@@ -333,11 +333,15 @@ func (s *userServer) run() error {
args = append(args, s.Name, s.Path)
}
var cmd *exec.Cmd
if s.canSudo() {
if su := s.canSU(); su != "" {
s.logf("starting taildrive file server as user %q", s.username)
allArgs := []string{"-n", "-u", s.username, s.executable}
allArgs = append(allArgs, args...)
cmd = exec.Command("sudo", allArgs...)
// Quote and escape arguments. Use single quotes to prevent shell substitutions.
for i, arg := range args {
args[i] = "'" + strings.ReplaceAll(arg, "'", "'\"'\"'") + "'"
}
cmdString := fmt.Sprintf("%s %s", s.executable, strings.Join(args, " "))
allArgs := []string{s.username, "-c", cmdString}
cmd = exec.Command(su, allArgs...)
} else {
// If we were root, we should have been able to sudo as a specific
// user, but let's check just to make sure, since we never want to
@@ -405,16 +409,28 @@ var writeMethods = map[string]bool{
"DELETE": true,
}
// canSudo checks wether we can sudo -u the configured executable as the
// configured user by attempting to call the executable with the '-h' flag to
// print help.
func (s *userServer) canSudo() bool {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "sudo", "-n", "-u", s.username, s.executable, "-h").Run(); err != nil {
return false
// canSU checks whether the current process can run su with the right username.
// If su can be run, this returns the path to the su command.
// If not, this returns the empty string "".
func (s *userServer) canSU() string {
su, err := exec.LookPath("su")
if err != nil {
s.logf("can't find su command: %v", err)
return ""
}
return true
// First try to execute su <user> -c true to make sure we can su.
err = exec.Command(
su,
s.username,
"-c", "true",
).Run()
if err != nil {
s.logf("su check failed: %s", err)
return ""
}
return su
}
// assertNotRoot returns an error if the current user has UID 0 or if we cannot

View File

@@ -20,16 +20,18 @@ import (
"fmt"
"io"
"log"
"maps"
"os"
"path/filepath"
"runtime"
"sort"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/opt"
"tailscale.com/version"
"tailscale.com/version/distro"
@@ -76,12 +78,7 @@ func LogCurrent(logf logf) {
mu.Lock()
defer mu.Unlock()
list := make([]string, 0, len(set))
for k := range set {
list = append(list, k)
}
sort.Strings(list)
for _, k := range list {
for _, k := range slices.Sorted(maps.Keys(set)) {
logf("envknob: %s=%q", k, set[k])
}
}
@@ -406,6 +403,19 @@ func SSHIgnoreTailnetPolicy() bool { return Bool("TS_DEBUG_SSH_IGNORE_TAILNET_PO
// TKASkipSignatureCheck reports whether to skip node-key signature checking for development.
func TKASkipSignatureCheck() bool { return Bool("TS_UNSAFE_SKIP_NKS_VERIFICATION") }
// App returns the tailscale app type of this instance, if set via
// TS_INTERNAL_APP env var. TS_INTERNAL_APP can be used to set app type for
// components that wrap tailscaled, such as containerboot. App type is intended
// to only be used to set known predefined app types, such as Tailscale
// Kubernetes Operator components.
func App() string {
a := os.Getenv("TS_INTERNAL_APP")
if a == kubetypes.AppConnector || a == kubetypes.AppEgressProxy || a == kubetypes.AppIngressProxy || a == kubetypes.AppIngressResource {
return a
}
return ""
}
// CrashOnUnexpected reports whether the Tailscale client should panic
// on unexpected conditions. If TS_DEBUG_CRASH_ON_UNEXPECTED is set, that's
// used. Otherwise the default value is true for unstable builds.

12
flake.lock generated
View File

@@ -21,11 +21,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -36,11 +36,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1707619277,
"narHash": "sha256-vKnYD5GMQbNQyyQm4wRlqi+5n0/F1hnvqSQgaBy4BqY=",
"lastModified": 1724748588,
"narHash": "sha256-NlpGA4+AIf1dKNq76ps90rxowlFXUsV9x7vK/mN37JM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f3a93440fbfff8a74350f4791332a19282cc6dc8",
"rev": "a6292e34000dc93d43bccf78338770c1c5ec8a99",
"type": "github"
},
"original": {

View File

@@ -40,7 +40,12 @@
};
};
outputs = { self, nixpkgs, flake-utils, flake-compat }: let
outputs = {
self,
nixpkgs,
flake-utils,
flake-compat,
}: let
# tailscaleRev is the git commit at which this flake was imported,
# or the empty string when building from a local checkout of the
# tailscale repo.
@@ -62,36 +67,37 @@
# So really, this flake is for tailscale devs to dogfood with, if
# you're an end user you should be prepared for this flake to not
# build periodically.
tailscale = pkgs: pkgs.buildGo122Module rec {
name = "tailscale";
tailscale = pkgs:
pkgs.buildGo123Module rec {
name = "tailscale";
src = ./.;
vendorHash = pkgs.lib.fileContents ./go.mod.sri;
nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.makeWrapper ];
ldflags = ["-X tailscale.com/version.gitCommitStamp=${tailscaleRev}"];
CGO_ENABLED = 0;
subPackages = [ "cmd/tailscale" "cmd/tailscaled" ];
doCheck = false;
src = ./.;
vendorHash = pkgs.lib.fileContents ./go.mod.sri;
nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [pkgs.makeWrapper];
ldflags = ["-X tailscale.com/version.gitCommitStamp=${tailscaleRev}"];
CGO_ENABLED = 0;
subPackages = ["cmd/tailscale" "cmd/tailscaled"];
doCheck = false;
# NOTE: We strip the ${PORT} and $FLAGS because they are unset in the
# environment and cause issues (specifically the unset PORT). At some
# point, there should be a NixOS module that allows configuration of these
# things, but for now, we hardcode the default of port 41641 (taken from
# ./cmd/tailscaled/tailscaled.defaults).
postInstall = pkgs.lib.optionalString pkgs.stdenv.isLinux ''
wrapProgram $out/bin/tailscaled --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.iproute2 pkgs.iptables pkgs.getent pkgs.shadow ]}
wrapProgram $out/bin/tailscale --suffix PATH : ${pkgs.lib.makeBinPath [ pkgs.procps ]}
# NOTE: We strip the ${PORT} and $FLAGS because they are unset in the
# environment and cause issues (specifically the unset PORT). At some
# point, there should be a NixOS module that allows configuration of these
# things, but for now, we hardcode the default of port 41641 (taken from
# ./cmd/tailscaled/tailscaled.defaults).
postInstall = pkgs.lib.optionalString pkgs.stdenv.isLinux ''
wrapProgram $out/bin/tailscaled --prefix PATH : ${pkgs.lib.makeBinPath [pkgs.iproute2 pkgs.iptables pkgs.getent pkgs.shadow]}
wrapProgram $out/bin/tailscale --suffix PATH : ${pkgs.lib.makeBinPath [pkgs.procps]}
sed -i \
-e "s#/usr/sbin#$out/bin#" \
-e "/^EnvironmentFile/d" \
-e 's/''${PORT}/41641/' \
-e 's/$FLAGS//' \
./cmd/tailscaled/tailscaled.service
sed -i \
-e "s#/usr/sbin#$out/bin#" \
-e "/^EnvironmentFile/d" \
-e 's/''${PORT}/41641/' \
-e 's/$FLAGS//' \
./cmd/tailscaled/tailscaled.service
install -D -m0444 -t $out/lib/systemd/system ./cmd/tailscaled/tailscaled.service
'';
};
install -D -m0444 -t $out/lib/systemd/system ./cmd/tailscaled/tailscaled.service
'';
};
# This whole blob makes the tailscale package available for all
# OS/CPU combos that nix supports, as well as a dev shell so that
@@ -112,12 +118,16 @@
gotools
graphviz
perl
go_1_22
go_1_23
yarn
# qemu and e2fsprogs are needed for natlab
qemu
e2fsprogs
];
};
};
in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
}
# nix-direnv cache busting line: sha256-M5e5dE1gGW3ly94r3SxCsBmVwbBmhVtaVDW691vxG/8=
# nix-direnv cache busting line: sha256-xO1DuLWi6/lpA9ubA2ZYVJM+CkVNA5IaVGZxX9my0j0=

17
go.mod
View File

@@ -1,13 +1,15 @@
module tailscale.com
go 1.22.0
go 1.23.1
require (
filippo.io/mkcert v1.4.4
fyne.io/systray v1.11.0
github.com/akutz/memconn v0.1.0
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa
github.com/andybalholm/brotli v1.1.0
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
github.com/atotto/clipboard v0.1.4
github.com/aws/aws-sdk-go-v2 v1.24.1
github.com/aws/aws-sdk-go-v2/config v1.26.5
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64
@@ -28,6 +30,7 @@ require (
github.com/dsnet/try v0.0.3
github.com/elastic/crd-ref-docs v0.0.12
github.com/evanw/esbuild v0.19.11
github.com/fogleman/gg v1.3.0
github.com/frankban/quicktest v1.14.6
github.com/fxamacker/cbor/v2 v2.6.0
github.com/gaissmai/bart v0.11.1
@@ -45,7 +48,7 @@ require (
github.com/google/uuid v1.6.0
github.com/goreleaser/nfpm/v2 v2.33.1
github.com/hdevalence/ed25519consensus v0.2.0
github.com/illarion/gonotify v1.0.1
github.com/illarion/gonotify/v2 v2.0.3
github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2
github.com/jellydator/ttlcache/v3 v3.1.0
@@ -78,17 +81,16 @@ require (
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/tailscale/mkctr v0.0.0-20240628074852-17ca944da6ba
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e
github.com/tc-hib/winres v0.2.1
github.com/tcnksm/go-httpstat v0.2.0
github.com/toqueteos/webbrowser v1.2.0
github.com/u-root/u-root v0.12.0
github.com/vishvananda/netlink v1.2.1-beta.2
github.com/vishvananda/netns v0.0.4
go.uber.org/zap v1.27.0
go4.org/mem v0.0.0-20220726221520-4f986261bf13
@@ -107,7 +109,7 @@ require (
golang.zx2c4.com/wireguard/windows v0.5.3
gopkg.in/square/go-jose.v2 v2.6.0
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987
honnef.co/go/tools v0.4.6
honnef.co/go/tools v0.5.1
k8s.io/api v0.30.3
k8s.io/apimachinery v0.30.3
k8s.io/apiserver v0.30.3
@@ -131,6 +133,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobuffalo/flect v1.0.2 // indirect
github.com/goccy/go-yaml v1.12.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
@@ -151,7 +154,7 @@ require (
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/Antonboom/errname v0.1.9 // indirect
github.com/Antonboom/nilnil v0.1.4 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/Djarvur/go-err113 v0.1.0 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v2 v2.3.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect

View File

@@ -1 +1 @@
sha256-M5e5dE1gGW3ly94r3SxCsBmVwbBmhVtaVDW691vxG/8=
sha256-xO1DuLWi6/lpA9ubA2ZYVJM+CkVNA5IaVGZxX9my0j0=

30
go.sum
View File

@@ -46,6 +46,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/Abirdcfly/dupword v0.0.11 h1:z6v8rMETchZXUIuHxYNmlUAuKuB21PeaSymTed16wgU=
github.com/Abirdcfly/dupword v0.0.11/go.mod h1:wH8mVGuf3CP5fsBTkfWwwwKTjDnVVCxtU8d8rgeVYXA=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
@@ -57,8 +59,8 @@ github.com/Antonboom/nilnil v0.1.4/go.mod h1:iOov/7gRcXkeEU+EMGpBu2ORih3iyVEiWje
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
@@ -112,6 +114,8 @@ github.com/ashanbrown/forbidigo v1.5.1 h1:WXhzLjOlnuDYPYQo/eFlcFMi8X/kLfvWLYu6CS
github.com/ashanbrown/forbidigo v1.5.1/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU=
github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s=
github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
@@ -306,6 +310,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/firefart/nonamedreturns v1.0.4 h1:abzI1p7mAEPYuR4A+VLKn4eNDOycjYo2phmY9sfv40Y=
github.com/firefart/nonamedreturns v1.0.4/go.mod h1:TDhe/tjI1BXo48CmYbUduTV7BdIga8MAO/xbKdcVsGI=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -400,6 +406,8 @@ github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14j
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -562,8 +570,8 @@ github.com/hugelgupf/vmtest v0.0.0-20240102225328-693afabdd27f h1:ov45/OzrJG8EKb
github.com/hugelgupf/vmtest v0.0.0-20240102225328-693afabdd27f/go.mod h1:JoDrYMZpDPYo6uH9/f6Peqms3zNNWT2XiGgioMOIGuI=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio=
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE=
github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A=
github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
@@ -930,16 +938,16 @@ github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29X
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/mkctr v0.0.0-20240628074852-17ca944da6ba h1:uNo1VCm/xg4alMkIKo8RWTKNx5y1otfVOcKbp+irkL4=
github.com/tailscale/mkctr v0.0.0-20240628074852-17ca944da6ba/go.mod h1:DxnqIXBplij66U2ZkL688xy07q97qQ83P+TVueLiHq4=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 h1:RNpJrXfI5u6e+uzyIzvmnXbhmhdRkVf//90sMBH3lso=
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc h1:cezaQN9pvKVaw56Ma5qr/G646uKIYP0yQf+OyWN/okc=
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
@@ -980,8 +988,6 @@ github.com/uudashr/gocognit v1.0.6 h1:2Cgi6MweCsdB6kpcVQp7EW4U23iBFQWfTXiWlyp842
github.com/uudashr/gocognit v1.0.6/go.mod h1:nAIUuVBnYU7pcninia3BHOvQkpQCeO76Uscky5BOwcY=
github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts=
github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk=
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
@@ -1504,8 +1510,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.4.6 h1:oFEHCKeID7to/3autwsWfnuv69j3NsfcXbvJKuIcep8=
honnef.co/go/tools v0.4.6/go.mod h1:+rnGS1THNh8zMwnd2oVOTL9QF6vmfyG6ZXBULae2uc0=
honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I=
honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
k8s.io/api v0.30.3 h1:ImHwK9DCsPA9uoU3rVh4QHAHHK5dTSv1nxJUapx8hoQ=

View File

@@ -1 +1 @@
tailscale.go1.22
tailscale.go1.23

View File

@@ -1 +1 @@
22ef9eb38e9a2d21b4a45f7adc75addb05f3efb8
ed9dc37b2b000f376a3e819cbb159e2c17a2dac6

View File

@@ -11,6 +11,7 @@ package main
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"flag"
@@ -29,6 +30,7 @@ import (
var (
app = flag.String("app", "tsapp", "appliance name; one of the subdirectories of gokrazy/")
bucket = flag.String("bucket", "tskrazy-import", "S3 bucket to upload disk image to while making AMI")
goArch = flag.String("arch", cmp.Or(os.Getenv("GOARCH"), "amd64"), "GOARCH architecture to build for: arm64 or amd64")
build = flag.Bool("build", false, "if true, just build locally and stop, without uploading")
)
@@ -99,12 +101,12 @@ func buildImage() error {
return err
}
if fi, err := os.Stat(filepath.Join(dir, *app)); err != nil || !fi.IsDir() {
return fmt.Errorf("in wrong directorg %v; no %q subdirectory found", dir, *app)
return fmt.Errorf("in wrong directory %v; no %q subdirectory found", dir, *app)
}
// Build the tsapp.img
var buf bytes.Buffer
cmd := exec.Command("go", "run",
"-exec=env GOOS=linux GOARCH=amd64 ",
"-exec=env GOOS=linux GOARCH="+*goArch+" ",
"github.com/gokrazy/tools/cmd/gok",
"--parent_dir="+dir,
"--instance="+*app,
@@ -250,9 +252,18 @@ func waitForImportSnapshot(importTaskID string) (snapID string, err error) {
}
func makeAMI(name, ebsSnapID string) (ami string, err error) {
var arch string
switch *goArch {
case "arm64":
arch = "arm64"
case "amd64":
arch = "x86_64"
default:
return "", fmt.Errorf("unknown arch %q", *goArch)
}
out, err := exec.Command("aws", "ec2", "register-image",
"--name", name,
"--architecture", "x86_64",
"--architecture", arch,
"--root-device-name", "/dev/sda",
"--ena-support",
"--imds-support", "v2.0",

View File

@@ -1,6 +1,6 @@
module tailscale.com/gokrazy
go 1.22
go 1.23.1
require github.com/gokrazy/tools v0.0.0-20240730192548-9f81add3a91e

View File

@@ -0,0 +1,6 @@
# NATLab Linux test Appliance
This is the definition of the NATLab Linux test appliance image.
It's similar to ../tsapp, but optimized for running in qemu in NATLab.
See ../tsapp/README.md for more info.

View File

@@ -0,0 +1,18 @@
module gokrazy/build/tsapp
go 1.22.2
require (
github.com/gokrazy/gokrazy v0.0.0-20240525065858-dedadaf38803 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/google/renameio/v2 v2.0.0 // indirect
github.com/josharian/native v1.0.0 // indirect
github.com/mdlayher/packet v1.0.0 // indirect
github.com/mdlayher/socket v0.2.3 // indirect
github.com/rtr7/dhcp4 v0.0.0-20220302171438-18c84d089b46 // indirect
github.com/vishvananda/netlink v1.1.0 // indirect
github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.20.0 // indirect
)

View File

@@ -0,0 +1,39 @@
github.com/gokrazy/gokrazy v0.0.0-20240525065858-dedadaf38803 h1:gdGRW/wXHPJuZgZD931Lh75mdJfzEEXrL+Dvi97Ck3A=
github.com/gokrazy/gokrazy v0.0.0-20240525065858-dedadaf38803/go.mod h1:NHROeDlzn0icUl3f+tEYvGGpcyBDMsr3AvKLHOWRe5M=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg=
github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=
github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU=
github.com/mdlayher/socket v0.2.3 h1:XZA2X2TjdOwNoNPVPclRCURoX/hokBY8nkTmRZFEheM=
github.com/mdlayher/socket v0.2.3/go.mod h1:bz12/FozYNH/VbvC3q7TRIK/Y6dH1kCKsXaUeXi/FmY=
github.com/rtr7/dhcp4 v0.0.0-20220302171438-18c84d089b46 h1:3psQveH4RUiv5yc3p7kRySilf1nSXLQhAvJFwg4fgnE=
github.com/rtr7/dhcp4 v0.0.0-20220302171438-18c84d089b46/go.mod h1:Ng1F/s+z0zCMsbEFEneh+30LJa9DrTfmA+REbEqcTPk=
github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg=
github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -0,0 +1,15 @@
module gokrazy/build/tsapp
go 1.22.2
require (
github.com/gokrazy/gokrazy v0.0.0-20240802144848-676865a4e84f // indirect
github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5 // indirect
github.com/google/renameio/v2 v2.0.0 // indirect
github.com/kenshaw/evdev v0.1.0 // indirect
github.com/mdlayher/watchdog v0.0.0-20201005150459-8bdc4f41966b // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.20.0 // indirect
)
replace github.com/gokrazy/gokrazy => github.com/tailscale/gokrazy v0.0.0-20240812224643-6b21ddf64678

View File

@@ -0,0 +1,23 @@
github.com/gokrazy/gokrazy v0.0.0-20240525065858-dedadaf38803 h1:gdGRW/wXHPJuZgZD931Lh75mdJfzEEXrL+Dvi97Ck3A=
github.com/gokrazy/gokrazy v0.0.0-20240525065858-dedadaf38803/go.mod h1:NHROeDlzn0icUl3f+tEYvGGpcyBDMsr3AvKLHOWRe5M=
github.com/gokrazy/internal v0.0.0-20240510165500-68dd68393b7a h1:FKeN678rNpKTpWRdFbAhYL9mWzPu57R5XPXCR3WmXdI=
github.com/gokrazy/internal v0.0.0-20240510165500-68dd68393b7a/go.mod h1:t3ZirVhcs9bH+fPAJuGh51rzT7sVCZ9yfXvszf0ZjF0=
github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5 h1:XDklMxV0pE5jWiNaoo5TzvWfqdoiRRScmr4ZtDzE4Uw=
github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5/go.mod h1:t3ZirVhcs9bH+fPAJuGh51rzT7sVCZ9yfXvszf0ZjF0=
github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg=
github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=
github.com/kenshaw/evdev v0.1.0 h1:wmtceEOFfilChgdNT+c/djPJ2JineVsQ0N14kGzFRUo=
github.com/kenshaw/evdev v0.1.0/go.mod h1:B/fErKCihUyEobz0mjn2qQbHgyJKFQAxkXSvkeeA/Wo=
github.com/mdlayher/watchdog v0.0.0-20201005150459-8bdc4f41966b h1:7tUBfsEEBWfFeHOB7CUfoOamak+Gx/BlirfXyPk1WjI=
github.com/mdlayher/watchdog v0.0.0-20201005150459-8bdc4f41966b/go.mod h1:bmoJUS6qOA3uKFvF3KVuhf7mU1KQirzQMeHXtPyKEqg=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/tailscale/gokrazy v0.0.0-20240602215456-7b9b6bbf726a h1:7dnA8x14JihQmKbPr++Y5CCN/XSyDmOB6cXUxcIj6VQ=
github.com/tailscale/gokrazy v0.0.0-20240602215456-7b9b6bbf726a/go.mod h1:NHROeDlzn0icUl3f+tEYvGGpcyBDMsr3AvKLHOWRe5M=
github.com/tailscale/gokrazy v0.0.0-20240802144848-676865a4e84f h1:ZSAGWpgs+6dK2oIz5OR+HUul3oJbnhFn8YNgcZ3d9SQ=
github.com/tailscale/gokrazy v0.0.0-20240802144848-676865a4e84f/go.mod h1:+/WWMckeuQt+DG6690A6H8IgC+HpBFq2fmwRKcSbxdk=
github.com/tailscale/gokrazy v0.0.0-20240812224643-6b21ddf64678 h1:2B8/FbIRqmVgRUulQ4iu1EojniufComYe5Yj4BtIn1c=
github.com/tailscale/gokrazy v0.0.0-20240812224643-6b21ddf64678/go.mod h1:+/WWMckeuQt+DG6690A6H8IgC+HpBFq2fmwRKcSbxdk=
golang.org/x/sys v0.0.0-20201005065044-765f4ea38db3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -0,0 +1,5 @@
module gokrazy/build/natlabapp.arm64
go 1.23.1
require github.com/gokrazy/kernel.arm64 v0.0.0-20240830035047-cdba87a9eb0e // indirect

View File

@@ -0,0 +1,2 @@
github.com/gokrazy/kernel.arm64 v0.0.0-20240830035047-cdba87a9eb0e h1:D9QYleJ7CI4p7gpgUT1mPgAlWMi5au6yOiE8/qC5PhE=
github.com/gokrazy/kernel.arm64 v0.0.0-20240830035047-cdba87a9eb0e/go.mod h1:WWx72LXHEesuJxbopusRfSoKJQ6ffdwkT0DZditdrLo=

View File

@@ -0,0 +1,5 @@
module gokrazy/build/tsapp
go 1.22.2
require github.com/gokrazy/serial-busybox v0.0.0-20220918193710-d728912733ca // indirect

View File

@@ -0,0 +1,26 @@
github.com/beevik/ntp v0.2.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gokrazy/gokrazy v0.0.0-20200501080617-f3445e01a904 h1:eqfH4A/LLgxv5RvqEXwVoFvfmpRa8+TokRjB5g6xBkk=
github.com/gokrazy/gokrazy v0.0.0-20200501080617-f3445e01a904/go.mod h1:pq6rGHqxMRPSaTXaCMzIZy0wLDusAJyoVNyNo05RLs0=
github.com/gokrazy/internal v0.0.0-20200407075822-660ad467b7c9 h1:x5jR/nNo4/kMSoNo/nwa2xbL7PN1an8S3oIn4OZJdec=
github.com/gokrazy/internal v0.0.0-20200407075822-660ad467b7c9/go.mod h1:LA5TQy7LcvYGQOy75tkrYkFUhbV2nl5qEBP47PSi2JA=
github.com/gokrazy/serial-busybox v0.0.0-20220918193710-d728912733ca h1:x0eSjuFy8qsRctVHeWm3EC474q3xm4h3OOOrYpcqyyA=
github.com/gokrazy/serial-busybox v0.0.0-20220918193710-d728912733ca/go.mod h1:OYcG5tSb+QrelmUOO4EZVUFcIHyyZb0QDbEbZFUp1TA=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gopacket v1.1.16/go.mod h1:UCLx9mCmAwsVbn6qQl1WIEt2SO7Nd2fD0th1TBAsqBw=
github.com/mdlayher/raw v0.0.0-20190303161257-764d452d77af/go.mod h1:rC/yE65s/DoHB6BzVOUBNYBGTg772JVytyAytffIZkY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rtr7/dhcp4 v0.0.0-20181120124042-778e8c2e24a5/go.mod h1:FwstIpm6vX98QgtR8KEwZcVjiRn2WP76LjXAHj84fK0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200406155108-e3b113bbe6a4 h1:c1Sgqkh8v6ZxafNGG64r8C8UisIW2TKMJN8P86tKjr0=
golang.org/x/sys v0.0.0-20200406155108-e3b113bbe6a4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -0,0 +1,5 @@
module gokrazy/build/tsapp
go 1.22.2
require github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e // indirect

View File

@@ -0,0 +1,4 @@
github.com/tailscale/gokrazy-kernel v0.0.0-20240530042707-3f95c886bcf2 h1:xzf+cMvBJBcA/Av7OTWBa0Tjrbfcy00TeatJeJt6zrY=
github.com/tailscale/gokrazy-kernel v0.0.0-20240530042707-3f95c886bcf2/go.mod h1:7Mth+m9bq2IHusSsexMNyupHWPL8RxwOuSvBlSGtgDY=
github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e h1:tyUUgeRPGHjCZWycRnhdx8Lx9DRkjl3WsVUxYMrVBOw=
github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e/go.mod h1:7Mth+m9bq2IHusSsexMNyupHWPL8RxwOuSvBlSGtgDY=

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