Compare commits

...

304 Commits

Author SHA1 Message Date
Andrea Gottardo
aa448d5a99 VERSION.txt: this is v1.72.0
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-08-19 10:07:21 -07:00
Anton Tolchanov
ee976ad704 posture: deduplicate MAC addresses before returning them
Some machines have multiple network interfaces with the same MAC
address.

Updates tailscale/corp#21371

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-16 16:22:19 +01:00
Andrea Gottardo
5cbbb48c2e health/dns: reduce severity of DNS unavailable warning (#13152)
`DNS unavailable` was marked as a high severity warning. On Android (and other platforms), these trigger a system notification. Here we reduce the severity level to medium. A medium severity warning will still display the warning icon on platforms with a tray icon because of the `ImpactsConnectivity=true` flag being set here, but it won't show a notification anymore. If people enter an area with bad cellular reception, they're bound to receive so many of these notifications and we need to reduce notification fatigue.

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
2024-08-16 11:12:06 -04:00
Jordan Whited
ccf091e4a6 wgengine/magicsock: don't upgrade to linuxBatchingConn on Android (#13161)
In a93dc6cdb1 tryUpgradeToBatchingConn()
moved to build tag gated files, but the runtime.GOOS condition excluding
Android was removed unintentionally from batching_conn_linux.go. Add it
back.

Updates tailscale/corp#22348

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-15 14:54:25 -07:00
License Updater
cc136a58ea licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-08-15 14:38:12 -07:00
Andrew Lytvynov
d88be7cddf safeweb: add Server.Close method (#13160)
Updates https://github.com/tailscale/corp/issues/14881

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-08-15 10:49:04 -07:00
Andrew Dunham
e107977f75 wgengine/magicsock: disable SIO_UDP_NETRESET on Windows
By default, Windows sets the SIO_UDP_CONNRESET and SIO_UDP_NETRESET
options on created UDP sockets. These behaviours make the UDP socket
ICMP-aware; when the system gets an ICMP message (e.g. an "ICMP Port
Unreachable" message, in the case of SIO_UDP_CONNRESET), it will cause
the underlying UDP socket to throw an error. Confusingly, this can occur
even on reads, if the same UDP socket is used to write a packet that
triggers this response.

The Go runtime disabled the SIO_UDP_CONNRESET behavior in 3114bd6, but
did not change SIO_UDP_NETRESET–probably because that socket option
isn't documented particularly well.

Various other networking code seem to disable this behaviour, such as
the Godot game engine (godotengine/godot#22332) and the Eclipse TCF
agent (link below). Others appear to work around this by ignoring the
error returned (anacrolix/dht#16, among others).

For now, until it's clear whether this ends up in the upstream Go
implementation or not, let's also disable the SIO_UDP_NETRESET in a
similar manner to SIO_UDP_CONNRESET.

Eclipse TCF agent: https://gitlab.eclipse.org/eclipse/tcf/tcf.agent/-/blob/master/agent/tcf/framework/mdep.c

Updates #10976
Updates golang/go#68614

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I70a2f19855f8dec1bfb82e63f6d14fc4a22ed5c3
2024-08-15 12:11:33 -04:00
Flakes Updater
db4247f705 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-08-14 21:30:13 -07:00
Kyle Carberry
6c852fa817 go.{mod,sum}: migrate from nhooyr.io/websocket to github.com/coder/websocket
Coder has just adopted nhooyr/websocket which unfortunately changes the import path.

`github.com/coder/coder` imports `tailscale.com/net/wsconn` which was still pointing
to `nhooyr.io/websocket`, but this change updates it.

See https://coder.com/blog/websocket

Updates #13154

Change-Id: I3dec6512472b14eae337ae22c5bcc1e3758888d5
Signed-off-by: Kyle Carberry <kyle@carberry.com>
2024-08-14 21:23:49 -07:00
Nick Khyl
f8f9f05ffe cmd/viewer: add support for map-like container types
This PR modifies viewTypeForContainerType to use the last type parameter of a container type
as the value type, enabling the implementation of map-like container types where the second-to-last
(usually first) type parameter serves as the key type.

It also adds a MapContainer type to test the code generation.

Updates #12736

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-14 16:33:51 -05:00
Jordan Whited
2f27319baf wgengine/netstack: disable gVisor TCP GRO for Linux (#13138)
A SIGSEGV was observed around packet merging logic in gVisor's GRO
package.

Updates tailscale/corp#22353

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-14 11:36:48 -07:00
Brad Fitzpatrick
2dd71e64ac wgengine/magicsock: log when a ReceiveFunc fails
Updates #10976

Change-Id: I86d30151a25c7d42ed36e273fb207873f4acfdb4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-14 10:34:55 -07:00
Percy Wegmann
74b9fa1348 ipn/localapi: only flush relevant data in multiFilePostResponseWriter.Flush()
This prevents two things:

1. Crashing if there's no response body
2. Sending a nonsensical 0 response status code

Updates tailscale/corp#22357

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-08-14 12:28:40 -05:00
Irbe Krumina
a15ff1bade cmd/k8s-operator,k8s-operator/sessionrecording: support recording kubectl exec sessions over WebSockets (#12947)
cmd/k8s-operator,k8s-operator/sessionrecording: support recording WebSocket sessions

Kubernetes currently supports two streaming protocols, SPDY and WebSockets.
WebSockets are replacing SPDY, see
https://github.com/kubernetes/enhancements/issues/4006.
We were currently only supporting SPDY, erroring out if session
was not SPDY and relying on the kube's built-in SPDY fallback.

This PR:

- adds support for parsing contents of 'kubectl exec' sessions streamed
over WebSockets

- adds logic to distinguish 'kubectl exec' requests for a SPDY/WebSockets
sessions and call the relevant handler

Updates tailscale/corp#19821

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-08-14 17:57:50 +01:00
Brad Fitzpatrick
4c2e978f1e cmd/tailscale/cli: support passing network lock keys via files
Fixes tailscale/corp#22356

Change-Id: I959efae716a22bcf582c20d261fb1b57bacf6dd9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-14 09:18:34 -07:00
cai.li
2506bf5b06 fix #13076: codegen error when using anonymous struct
Signed-off-by: cai.li <cai.li@qingteng.cn>
2024-08-13 23:41:39 -05:00
Irbe Krumina
b9f42814b5 cmd/containerboot: optionally serve health check endpoint (#12899)
Add functionality to optionally serve a health check endpoint
(off by default).
Users can enable health check endpoint by setting
TS_HEALTHCHECK_ADDR_PORT to [<addr>]:<port>.
Containerboot will then serve an unauthenticatd HTTP health check at
/healthz at that address. The health check returns 200 OK if the
node has at least one tailnet IP address, else returns 503.

Updates tailscale/tailscale#12898

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-08-14 05:28:29 +01:00
Flakes Updater
b4e595621f go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-08-13 16:37:46 -07:00
Aaron Bieber
c987cf1255 go.mod: pull in latest github.com/creack/pty
This latest version allows for building on various OpenBSD architectures.

(such as openbsd/riscv64)

Updates #8043

Change-Id: Ie9a8738e6aa96335214d5750e090db35e526a4a4
Signed-off-by: Aaron Bieber <aaron@bolddaemon.com>
2024-08-13 16:31:12 -07:00
Brad Fitzpatrick
02581b1603 gokrazy,tstest/integration/nat: add Gokrazy appliance just for natlab
... rather than abusing the generic tsapp.

Per discussion in https://github.com/gokrazy/gokrazy/pull/275

It also means we can remove stuff we don't need, like ntp or randomd.

Updates #13038

Change-Id: Iccf579c354bd3b5025d05fa1128e32f1d5bde4e4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-13 15:26:12 -07:00
Brad Fitzpatrick
b358f489b9 tstest/integration/nat: remove -audio none flag from qemu
It's too new to be supported in Debian bookworm so just remove it.
It doesn't seem to matter or help speed anything up.

Updates #13038

Change-Id: I39077ba8032bebecd75209552b88f1842c843c33
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-13 15:18:55 -07:00
Brad Fitzpatrick
d985da207f tstest/natlab/vnet: fix one-by-one from earlier numbering change
84adfa1ba3 made MAC addresses 1-based too, but didn't adjust this IP address
calculation which was based on the MAC address

Updates #13038

Change-Id: Idc112b303b0b85f41fe51fd61ce1c0d8a3f0f57e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-13 12:32:01 -07:00
Brad Fitzpatrick
b26c53368d tstest/integration/nat: make Tailscale status log print less spammy
No need to print all the internal fields. We only care about the BackendState.

Updates #13038

Change-Id: Iaa0e47ade3c6d30e1887ab1e2a7412ed4e0dab7d
2024-08-13 12:32:01 -07:00
Brad Fitzpatrick
eae6a00651 tstest/integration/nat: crank up verbosity of a failing test
Updates #13038

Change-Id: I36cde97b74e4a675b6c0f3be30f817bccdbe8715
2024-08-13 12:32:01 -07:00
Brad Fitzpatrick
b60a9fce4b gokrazy/tsapp: remove implicit heartbeat package
The heartbeat package does nothing if not configured anyway, so don't
even put it in the image and pay the cost of it running.

Updates #13038
Updates #1866

Change-Id: Id22c0fb1f8395ad21ab0e0350973d31730e8d39f
2024-08-13 12:32:01 -07:00
Brad Fitzpatrick
f79e688e0d cmd/tailscale/cli: fix gokrazy CLI-as-a-service detection
The change in b7e48058c8 was too loose; it also captured the CLI
being run as a child process under cmd/tta.

Updates #13038
Updates #1866

Change-Id: Id410b87132938dd38ed4dd3959473c5d0d242ff5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-13 11:29:25 -07:00
Irbe Krumina
adbab25bac cmd/k8s-operator: fix DNS reconciler for dual-stack clusters (#13057)
* cmd/k8s-operator: fix DNS reconciler for dual-stack clusters

This fixes a bug where DNS reconciler logic was always assuming
that no more than one EndpointSlice exists for a Service.
In fact, there can be multiple, for example, in dual-stack
clusters, but also in other cases this is valid (as per kube docs).
This PR:
- allows for multiple EndpointSlices
- picks out the ones for IPv4 family
- deduplicates addresses

Updates tailscale/tailscale#13056

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-08-13 18:42:01 +01:00
Brad Fitzpatrick
9f1d9d324d gokrazy/tsapp: remove builddirs packages that aren't in config.json
These three packages aren't in gokrazy/tsapp/config.json but
used to be. Unfortunately, that meant that were being included
in the resulting image. Apparently `gok` doesn't delete them or
warn about them being present on disk when they're moved from
the config file.

Updates #13038
Updates #1866

Change-Id: I54918a9e3286ea755b11dde5e9efdd433b8f8fb8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-13 10:32:43 -07:00
Brad Fitzpatrick
b7e48058c8 cmd/tailscale/cli: don't run CLI as a service on gokrazy
Updates #13038
Updates #1866

Change-Id: Ie3223573044a92f5715a827fb66cc6705b38004f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-13 10:32:33 -07:00
Brad Fitzpatrick
84adfa1ba3 tstest/natlab/vnet: standardize on 1-based naming of nodes, networks, MACs
We had a mix of 0-based and 1-based nodes and MACs in logs.

Updates #13038

Change-Id: I36d1b00f7f94b37b4ae2cd439bcdc5dbee6eda4d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-13 08:50:03 -07:00
Brad Fitzpatrick
10d0ce8dde tstest/natlab: get tailscaled logs from gokrazy via syslog
Using https://github.com/gokrazy/gokrazy/pull/275

This is much lower latency than logcatcher, which is higher latency
and chunkier. And this is better than getting it via 'tailscale debug
daemon-logs', which misses early interesting logs.

Updates #13038

Change-Id: I499ec254c003a9494c0e9910f9c650c8ac44ef33
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-13 07:56:29 -07:00
Brad Fitzpatrick
10662c4282 tstest/integration/nat: annotate test 'want' values, fail on mismatch
Updates #13038

Change-Id: Id711ee19e52a7051a2273c806b184c5571c6e24f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-12 20:49:10 -07:00
Nick Khyl
67df9abdc6 util/syspolicy/setting: add package that contains types for the next syspolicy PRs
Package setting contains types for defining and representing policy settings.
It facilitates the registration of setting definitions using Register and RegisterDefinition,
and the retrieval of registered setting definitions via Definitions and DefinitionOf.
This package is intended for use primarily within the syspolicy package hierarchy,
and added in a preparation for the next PRs.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-12 21:02:35 -05:00
Brad Fitzpatrick
a61825c7b8 cmd/tta, vnet: add host firewall, env var support, more tests
In particular, tests showing that #3824 works. But that test doesn't
actually work yet; it only gets a DERP connection. (why?)

Updates #13038

Change-Id: Ie1fd1b6a38d4e90fae7e72a0b9a142a95f0b2e8f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-12 15:32:12 -07:00
Brad Fitzpatrick
b692985aef client/tailscale: add LocalClient.OmitAuth for tests
Similar to UseSocketOnly, but pulled out separately in case
people are doing unknown weird things.

Updates #13038

Change-Id: I7478e5cb9794439b947440b831caa798941845ea
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-12 15:16:22 -07:00
Brad Fitzpatrick
0686bc8b19 cmd/tailscaled: add env knob to control default verbosity
Updates #13038

Change-Id: Ic0e6dfc7a8d127ab5ce0ae9aab9119c56e19b636
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-12 15:00:13 -07:00
Flakes Updater
0dd9f5397b go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-08-12 14:54:58 -07:00
Maisem Ali
10c2bee9e1 tstest/natlab/vnet: capture network wan/lan interfaces
Updates #13038

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-12 14:54:38 -07:00
Jordan Whited
7aec8d4e6b cmd/stunstamp: refactor connection construction (#13110)
getConns() is now responsible for returning both stable and unstable
conns. conn and measureFn are now passed together via connAndMeasureFn.
newConnAndMeasureFn() is responsible for constructing them.

TCP measurement timeouts are adjusted to more closely match netcheck.

Updates tailscale/corp#22114

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-12 14:09:45 -07:00
Jordan Whited
218110963d cmd/stunstamp: implement HTTPS & TCP latency measurements (#13082)
HTTPS mirrors current netcheck behavior and TCP uses tcp_info->rtt.

Updates tailscale/corp#22114

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-12 13:39:11 -07:00
Paul Scott
bc2744da4b tsweb: fix TestStdHandler_ConnectionClosedDuringBody flake (#13046)
Fixes #13017

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-08-12 16:30:32 +01:00
Brad Fitzpatrick
2e32abc3e2 cmd/tailscaled: allow setting env via linux cmdline for integration tests
Updates #13038

Change-Id: I51e016d0eb7c14647159706c08f017fdedd68e2a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-10 12:01:21 -07:00
Maisem Ali
ce4413a0bc client/tailscale: add Via to UserRuleMatch
This adds the Via field for the https://tailscale.com/kb/1378/via
feature to the ACLPreview response.

Updates tailscale/corp#22239

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-09 18:01:14 -07:00
Brad Fitzpatrick
2a88428f24 tstest/integration/nat: skip some tests by default without flags
Updates #13038

Change-Id: I7ebf8bd8590e65ce4d30dd9f03c713b77868fa36
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
44d634395b tstest/natlab/vnet: add easyAF
Endpoint-indepedent Mapping with only Address (but not port) dependent
filtering.

Updates #13038

Change-Id: I1ec88301acafcb79bf878f9600a7286e8af0f173
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Maisem Ali
d4cc074187 tstest/natlab/vnet: add pcap support
Updates #13038

Change-Id: I89ce2129fee856f97986d6313d2b661c76476c0c
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-09 09:06:54 -07:00
Maisem Ali
d0e8375b53 cmd/{tta,vnet}: proxy to gokrazy UI
Updates #13038

Change-Id: I1cacb1b0f8c3d0e4c36b7890155f7b1ad0d23575
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-09 09:06:54 -07:00
Maisem Ali
072d1a4b77 gokrazy: bump
Updates #13038

Change-Id: Ie1a5b8930d5cce6f45ce67102da06a9474444af7
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
194ff6ee3d tstest/integration/nat: add sameLAN node type
To test local connections.

Updates #13038

Change-Id: I575dcab31ca812edf7d04fa126772611cf89b9a7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
730fec1cfd tstest/integration/nat: add start of TestGrid
Updates #13038

Change-Id: I41d1c2bf20ae6dfbb071020d9dc2b742e7995835
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
f47a5fe52b vnet: reduce some log spam
Updates #13038

Change-Id: I76038a90dfde10a82063988a5b54190074d4b5c5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
bb3e95c40d vnet: fix port mapping (w/ maisem + andrew)
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Co-authored-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I703b39f05af2e3e1a979be8e77091586cb9ec3eb
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Maisem Ali
f8d23b3582 tstest/integration/nat: stream daemon logs directly
Updates #13038

Signed-off-by: Maisem Ali <maisem@tailscale.com>
Change-Id: I5da5706149c082c27d74c8b894bf53dd9b259e84
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
17a10f702f vnet: add network.logf
Updates #13038

Change-Id: Ia5a9359b8bfa18264d64600dfa1ef01eb8728dc2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
082e46b48d vnet: don't hard-code bradfitz or maisem in paths
Updates #13038

Change-Id: Ie8c7591fac3800bb3b7f8c35356cce309fd3c164
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
6798f8ea88 tstest/natlab/vnet: add port mapping
Updates #13038

Change-Id: Iaf274d250398973790873534b236d5cbb34fbe0e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Maisem Ali
12764e9db4 natlab: add NodeAgentClient
This adds a new NodeAgentClient type that can be used to
invoke the LocalAPI using the LocalClient instead of
handcrafted URLs. However, there are certain cases where
it does make sense for the node agent to provide more
functionality than whats possible with just the LocalClient,
as such it also exposes a http.Client to make requests directly.

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
1016aa045f hostinfo: add hostinfo.IsNATLabGuestVM
And don't make guests under vnet/natlab upload to logcatcher,
as there won't be a valid cert anyway.

Updates #13038

Change-Id: Ie1ce0139788036b8ecc1804549a9b5d326c5fef5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Brad Fitzpatrick
8594292aa4 vnet: add control/derps to test, stateful firewall
Updates #13038

Change-Id: Icd65b34c5f03498b5a7109785bb44692bce8911a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-09 09:06:54 -07:00
Jordan Whited
20691894f5 cmd/stunstamp: refactor to support multiple protocols (#13063)
'stun' has been removed from metric names and replaced with a protocol
label. This refactor is preparation work for HTTPS & ICMP support.

Updates tailscale/corp#22114

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-09 08:03:58 -07:00
Nick Khyl
f23932bd98 net/dns/resolver: log forwarded query details when TS_DEBUG_DNS_FORWARD_SEND is enabled
Troubleshooting DNS resolution issues often requires additional information.
This PR expands the effect of the TS_DEBUG_DNS_FORWARD_SEND envknob to forwarder.forwardWithDestChan,
and includes the request type, domain name length, and the first 3 bytes of the domain's SHA-256 hash in the output.

Fixes #13070

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-08 15:57:35 -05:00
Brad Fitzpatrick
a867a4869d go.toolchain.rev: bump Go toolchain for net pkg resolv.conf fix
Updates tailscale/corp#22206

Change-Id: I9d995d408d4be3fd552a0d6e12bf79db8461d802
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-08 13:35:40 -07:00
Andrew Lytvynov
c0c4791ce7 cmd/gitops-pusher: ignore previous etag if local acls match control (#13068)
In a situation when manual edits are made on the admin panel, around the
GitOps process, the pusher will be stuck if `--fail-on-manual-edits` is
set, as expected.

To recover from this, there are 2 options:
1. revert the admin panel changes to get back in sync with the code
2. check in the manual edits to code

The former will work well, since previous and local ETags will match
control ETag again. The latter will still fail, since local and control
ETags match, but previous does not.

For this situation, check the local ETag against control first and
ignore previous when things are already in sync.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-08-08 13:23:06 -07:00
Andrew Lytvynov
ad038f4046 cmd/gitops-pusher: add --fail-on-manual-edits flag (#13066)
For cases where users want to be extra careful about not overwriting
manual changes, add a flag to hard-fail. This is only useful if the etag
cache is persistent or otherwise reliable. This flag should not be used
in ephemeral CI workers that won't persist the cache.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-08-08 11:21:28 -07:00
Anton Tolchanov
46db698333 prober: make status page more clear
Updates tailscale/corp#20583

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-08 17:34:29 +01:00
Naman Sood
f79183dac7 cmd/tsidp: add funnel support (#12591)
* cmd/tsidp: add funnel support

Updates #10263.

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

* look past funnel-ingress-node to see who we're authenticating

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

* fix comment typo

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

* address review feedback, support Basic auth for /token

Turns out you need to support Basic auth if you do client ID/secret
according to OAuth.

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

* fix typos

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

* review fixes

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

* remove debugging log

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

* add comments, fix header

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

---------

Signed-off-by: Naman Sood <mail@nsood.in>
2024-08-08 10:46:45 -04:00
Brad Fitzpatrick
1ed958fe23 tstest/natlab/vnet: add start of virtual network-based NAT Lab
Updates #13038

Change-Id: I3c74120d73149c1329288621f6474bbbcaa7e1a6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-07 09:37:15 -07:00
Brad Fitzpatrick
6ca078c46e cmd/derper: move 204 handler from package main to derphttp
Updates #13038

Change-Id: I28a8284dbe49371cae0e9098205c7c5f17225b40
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-06 17:53:33 -07:00
Jordan Whited
a93dc6cdb1 wgengine/magicsock: refactor batchingUDPConn to batchingConn interface (#13042)
This commit adds a batchingConn interface, and renames batchingUDPConn
to linuxBatchingConn. tryUpgradeToBatchingConn() may return a platform-
specific implementation of batchingConn. So far only a Linux
implementation of this interface exists, but this refactor is being
done in anticipation of a Windows implementation.

Updates tailscale/corp#21874

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-06 09:00:28 -07:00
Anton Tolchanov
7bac5dffcb control/controlhttp: extract the last network connection
The same context we use for the HTTP request here might be re-used by
the dialer, which could result in `GotConn` being called multiple times.
We only care about the last one.

Fixes #13009

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-06 11:42:06 +01:00
Anton Tolchanov
b3fc345aba cmd/derpprobe: use a status page from the prober library
Updates tailscale/corp#20583

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-06 11:27:59 +01:00
Anton Tolchanov
9106187a95 prober: support JSON response in RunHandler
Updates tailscale/corp#20583

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-06 11:27:59 +01:00
Anton Tolchanov
9b08399d9e prober: add a status page handler
This change adds an HTTP handler with a table showing a list of all
probes, their status, and a button that allows triggering a specific
probe.

Updates tailscale/corp#20583

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-06 11:27:59 +01:00
Anton Tolchanov
153a476957 prober: add an HTTP endpoint for triggering a probe
- Keep track of the last 10 probe results and successful probe
  latencies;
- Add an HTTP handler that triggers a given probe by name and returns it
  result as a plaintext HTML page, showing recent probe results as a
  baseline

Updates tailscale/corp#20583

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-06 11:27:59 +01:00
Anton Tolchanov
227509547f {control,net}: close idle connections of custom transports
I noticed a few places with custom http.Transport where we are not
closing idle connections when transport is no longer used.

Updates tailscale/corp#21609

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-05 17:28:15 +01:00
VimT
e3f047618b net/socks5: support UDP
Updates #7581

Signed-off-by: VimT <me@vimt.me>
2024-08-05 09:25:24 -07:00
Kot C
91d2e1772d words: raccoon dog, dog with the raccoon in 'im
Signed-off-by: Kot C <kot@yukata.dev>
2024-08-05 09:24:33 -07:00
License Updater
3b6849e362 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-08-05 08:45:07 -07:00
Anton Tolchanov
0fd73746dd cmd/tailscale/cli: fix revoke-keys command name in CLI output
During review of #8644 the `recover-compromised-key` command was renamed
to `revoke-key`, but the old name remained in some messages printed by
the command.

Fixes tailscale/corp#19446

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-08-05 14:49:48 +01:00
Jordan Whited
17c88a19be net/captivedetection: mark TestAllEndpointsAreUpAndReturnExpectedResponse flaky (#13021)
Updates #13019

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-03 22:08:55 +00:00
Jordan Whited
25f0a3fc8f wgengine/netstack: use build tags to exclude gVisor GRO importation on iOS (#13015)
Updates tailscale/corp#22125

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-03 15:03:44 -07:00
Maisem Ali
a7a394e7d9 tstest/integration: mark TestNATPing flaky
Updates #12169

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-03 15:02:17 -07:00
Maisem Ali
07e2487c1d wgengine/capture: fix v6 field typo in wireshark dissector
It was using a v4 field for a v6 address.

Updates tailscale/corp#8020

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-03 14:56:17 -07:00
Maisem Ali
1dd9c44d51 tsweb: mark TestStdHandler_ConnectionClosedDuringBody flaky
Updates #13107

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-03 14:54:10 -07:00
Flakes Updater
0a6eb12f05 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-08-03 11:45:38 -07:00
Maisem Ali
f205efcf18 net/packet/checksum: fix v6 NAT
We were copying 12 out of the 16 bytes which meant that
the 1:1 NAT required would only work if the last 4 bytes
happened to match between the new and old address, something
that our tests accidentally had. Fix it by copying the full
16 bytes and make the tests also verify the addr and use rand
addresses.

Updates #9511

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-03 11:38:00 -07:00
Maisem Ali
a917718353 util/linuxfw: return nil interface not concrete type
It was returning a nil `*iptablesRunner` instead of a
nil `NetfilterRunner` interface which would then fail
checks later.

Fixes #13012

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-08-03 09:53:46 -07:00
Nick Khyl
4099a36468 util/winutil/gp: fix a busy loop bug
Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-08-02 20:16:41 -05:00
Jordan Whited
d9d9d525d9 wgengine/netstack: increase gVisor's TCP send and receive buffer sizes (#12994)
This commit increases gVisor's TCP max send (4->6MiB) and receive
(4->8MiB) buffer sizes on all platforms except iOS. These values are
biased towards higher throughput on high bandwidth-delay product paths.

The iperf3 results below demonstrate the effect of this commit between
two Linux computers with i5-12400 CPUs. 100ms of RTT latency is
introduced via Linux's traffic control network emulator queue
discipline.

The first set of results are from commit f0230ce prior to TCP buffer
resizing.

gVisor write direction:
Test Complete. Summary Results:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec   180 MBytes   151 Mbits/sec    0  sender
[  5]   0.00-10.10  sec   179 MBytes   149 Mbits/sec       receiver

gVisor read direction:
Test Complete. Summary Results:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.10  sec   337 MBytes   280 Mbits/sec   20 sender
[  5]   0.00-10.00  sec   323 MBytes   271 Mbits/sec         receiver

The second set of results are from this commit with increased TCP
buffer sizes.

gVisor write direction:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec   297 MBytes   249 Mbits/sec    0 sender
[  5]   0.00-10.10  sec   297 MBytes   247 Mbits/sec        receiver

gVisor read direction:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.10  sec   501 MBytes   416 Mbits/sec   17  sender
[  5]   0.00-10.00  sec   485 MBytes   407 Mbits/sec       receiver

Updates #9707
Updates tailscale/corp#22119

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-02 15:50:47 -07:00
Andrew Dunham
9939374c48 wgengine/magicsock: use cloud metadata to get public IPs
Updates #12774

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I1661b6a2da7966ab667b075894837afd96f4742f
2024-08-02 16:05:14 -04:00
Andrea Gottardo
4055b63b9b net/captivedetection: exclude cellular data interfaces (#13002)
Updates tailscale/tailscale#1634

This PR optimizes captive portal detection on Android and iOS by excluding cellular data interfaces (`pdp*` and `rmnet`). As cellular networks do not present captive portals, frequent network switches between Wi-Fi and cellular would otherwise trigger captive detection unnecessarily, causing battery drain.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-08-02 12:23:48 -07:00
Jordan Whited
f0230ce0b5 go.mod,net/tstun,wgengine/netstack: implement gVisor TCP GRO for Linux (#12921)
This commit implements TCP GRO for packets being written to gVisor on
Linux. Windows support will follow later. The wireguard-go dependency is
updated in order to make use of newly exported IP checksum functions.
gVisor is updated in order to make use of newly exported
stack.PacketBuffer GRO logic.

TCP throughput towards gVisor, i.e. TUN write direction, is dramatically
improved as a result of this commit. Benchmarks show substantial
improvement, sometimes as high as 2x. High bandwidth-delay product
paths remain receive window limited, bottlenecked by gVisor's default
TCP receive socket buffer size. This will be addressed in a  follow-on
commit.

The iperf3 results below demonstrate the effect of this commit between
two Linux computers with i5-12400 CPUs. There is roughly ~13us of round
trip latency between them.

The first result is from commit 57856fc without TCP GRO.

Starting Test: protocol: TCP, 1 streams, 131072 byte blocks
- - - - - - - - - - - - - - - - - - - - - - - - -
Test Complete. Summary Results:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  4.77 GBytes  4.10 Gbits/sec   20 sender
[  5]   0.00-10.00  sec  4.77 GBytes  4.10 Gbits/sec      receiver

The second result is from this commit with TCP GRO.

Starting Test: protocol: TCP, 1 streams, 131072 byte blocks
- - - - - - - - - - - - - - - - - - - - - - - - -
Test Complete. Summary Results:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  10.6 GBytes  9.14 Gbits/sec   20 sender
[  5]   0.00-10.00  sec  10.6 GBytes  9.14 Gbits/sec      receiver

Updates #6816

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-08-02 10:41:10 -07:00
Brad Fitzpatrick
cc370314e7 health: don't show login error details with context cancelations
Fixes #12991

Change-Id: I2a5e109395761b720ecf1069d0167cf0caf72876
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-08-01 08:29:27 -07:00
Aaron Klotz
655b4f8fc5 net/netns: remove some logspam by avoiding logging parse errors due to unspecified addresses
I updated the address parsing stuff to return a specific error for
unspecified hosts passed as empty strings, and look for that
when logging errors. I explicitly did not make parseAddress return a
netip.Addr containing an unspecified address because at this layer,
in the absence of any host, we don't necessarily know the address
family we're dealing with.

For the purposes of this code I think this is fine, at least until
we implement #12588.

Fixes #12979

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-07-31 12:34:16 -06:00
Brad Fitzpatrick
004dded0a8 net/tlsdial: relax self-signed cert health warning
It seems some security software or macOS itself might be MITMing TLS
(for ScreenTime?), so don't warn unless it fails x509 validation
against system roots.

Updates #3198

Change-Id: I6ea381b5bb6385b3d51da4a1468c0d803236b7bf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-31 10:03:48 -07:00
Aaron Klotz
0def4f8e38 net/netns: on Windows, fall back to default interface index when unspecified address is passed to ControlC and bindToInterfaceByRoute is enabled
We were returning an error instead of binding to the default interface.

Updates #12979

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-07-31 10:58:45 -06:00
Jordan Whited
7bc2ddaedc go.mod,net/tstun,wgengine/netstack: implement gVisor TCP GSO for Linux (#12869)
This commit implements TCP GSO for packets being read from gVisor on
Linux. Windows support will follow later. The wireguard-go dependency is
updated in order to make use of newly exported GSO logic from its tun
package.

A new gVisor stack.LinkEndpoint implementation has been established
(linkEndpoint) that is loosely modeled after its predecessor
(channel.Endpoint). This new implementation supports GSO of monster TCP
segments up to 64K in size, whereas channel.Endpoint only supports up to
32K. linkEndpoint will also be required for GRO, which will be
implemented in a follow-on commit.

TCP throughput from gVisor, i.e. TUN read direction, is dramatically
improved as a result of this commit. Benchmarks show substantial
improvement through a wide range of RTT and loss conditions, sometimes
as high as 5x.

The iperf3 results below demonstrate the effect of this commit between
two Linux computers with i5-12400 CPUs. There is roughly ~13us of round
trip latency between them.

The first result is from commit 57856fc without TCP GSO.

Starting Test: protocol: TCP, 1 streams, 131072 byte blocks
- - - - - - - - - - - - - - - - - - - - - - - - -
Test Complete. Summary Results:
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  2.51 GBytes  2.15 Gbits/sec  154 sender
[  5]   0.00-10.00  sec  2.49 GBytes  2.14 Gbits/sec      receiver

The second result is from this commit with TCP GSO.

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.6 GBytes  10.8 Gbits/sec    6 sender
[  5]   0.00-10.00  sec  12.6 GBytes  10.8 Gbits/sec      receiver

Updates #6816

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-07-31 09:42:11 -07:00
Andrea Gottardo
949b15d858 net/captivedetection: call SetHealthy once connectivity restored (#12974)
Fixes tailscale/tailscale#12973
Updates tailscale/tailscale#1634

There was a logic issue in the captive detection code we shipped in https://github.com/tailscale/tailscale/pull/12707.

Assume a captive portal has been detected, and the user notified. Upon switching to another Wi-Fi that does *not* have a captive portal, we were issuing a signal to interrupt any pending captive detection attempt. However, we were not also setting the `captive-portal-detected` warnable to healthy. The result was that any "captive portal detected" alert would not be cleared from the UI.

Also fixes a broken log statement value.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-07-30 13:39:25 -07:00
Jonathan Nobels
8a8ecac6a7 net/dns, cmd/tailscaled: plumb system health tracker into dns cleanup (#12969)
fixes tailscale#12968

The dns manager cleanup func was getting passed a nil
health tracker, which will panic.  Fixed to pass it
the system health tracker.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2024-07-30 12:54:03 -04:00
Irbe Krumina
eead25560f build_docker.sh: update script comment (#12970)
It is no longer correct to state that we don't support running Tailscale in containers or on Kubernetes.

Updates tailscale/tailscale#12842

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-30 15:12:43 +01:00
dependabot[bot]
1b64961320 build(deps): bump github.com/docker/docker (#12966)
Bumps [github.com/docker/docker](https://github.com/docker/docker) from 25.0.5+incompatible to 26.1.4+incompatible.
- [Release notes](https://github.com/docker/docker/releases)
- [Commits](https://github.com/docker/docker/compare/v25.0.5...v26.1.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-30 12:46:14 +01:00
Irbe Krumina
32308fcf71 Dockerfile: add a warning that this is not used to build our published images (#12955)
Add a warning that the Dockerfile in the OSS repo is not the
currently used mechanism to build the images we publish - for folks
who want to contribute to image build scripts or otherwise need to
understand the image build process that we use.

Updates#cleanup

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-30 12:22:53 +01:00
Flakes Updater
34de96d06e go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-07-29 19:40:24 -07:00
Brad Fitzpatrick
575feb486f util/osuser: turn wasm check into a const expression
All wasi* are GOARCH wasm, so check that instead.

Updates #12732

Change-Id: Id3cc346295c1641bcf80a6c5eb1ad65488509656
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-29 19:39:55 -07:00
Brad Fitzpatrick
2ab1d532e8 gokrazy/tsapp: add go.mod replacing two tailscale.com binaries with parent module
Updates #1866

Change-Id: I1ee7d41f7ee55806fb7ad94d0333dd0ec33d8efd
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-29 19:07:25 -07:00
Brad Fitzpatrick
360046e5c3 words: add some associated with scales
Updates tailscale/corp#14698

Change-Id: Ica7f179bd368d3c15f58fb236d377881cd80efcf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-29 15:18:08 -07:00
Andrew Dunham
35a8fca379 cmd/tailscale/cli: release portmap after netcheck
Updates #12954

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ic14f037b48a79b1263b140c6699579b466d89310
2024-07-29 14:10:32 -04:00
Jonathan Nobels
19b0c8a024 net/dns, health: raise health warning for failing forwarded DNS queries (#12888)
updates tailscale/corp#21823

Misconfigured, broken, or blocked DNS will often present as
"internet is broken'" to the end user.  This  plumbs the health tracker
into the dns manager and forwarder and adds a health warning
with a 5 second delay that is raised on failures in the forwarder and
lowered on successes.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2024-07-29 13:48:46 -04:00
Percy Wegmann
3088c6105e go.mod: pull in latest github.com/tailscale/xnet
This picks up https://github.com/tailscale/xnet/pull/1 so that
clients can move files even when holding only a lock for the source
file.

Updates #12941

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-07-29 10:41:53 -05:00
Irbe Krumina
a21bf100f3 cmd/k8s-operator,k8s-operator/sessionrecording,sessionrecording,ssh/tailssh: refactor session recording functionality (#12945)
cmd/k8s-operator,k8s-operator/sessionrecording,sessionrecording,ssh/tailssh: refactor session recording functionality

Refactor SSH session recording functionality (mostly the bits related to
Kubernetes API server proxy 'kubectl exec' session recording):

- move the session recording bits used by both Tailscale SSH
and the Kubernetes API server proxy into a shared sessionrecording package,
to avoid having the operator to import ssh/tailssh

- move the Kubernetes API server proxy session recording functionality
into a k8s-operator/sessionrecording package, add some abstractions
in preparation for adding support for a second streaming protocol (WebSockets)

Updates tailscale/corp#19821

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-29 13:57:11 +01:00
Paul Scott
1bf7ed0348 tsweb: add QuietLogging option (#12838)
Allows the use of tsweb.LogHandler exclusively for callbacks describing the
handler HTTP requests.

Fixes #12837

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-07-29 13:53:01 +01:00
Irbe Krumina
c5623e0471 go.{mod,sum},tstest/tools,k8s-operator,cmd/k8s-operator: autogenerate CRD API docs (#12884)
Re-instates the functionality that generates CRD API docs, but using
a different library as the one we were using earlier seemed to have
some issues with its Git history.
Also regenerates the docs (make kube-generate-all).

Updates tailscale/tailscale#12859

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-29 11:50:27 +01:00
Ross Williams
1bf82ddf84 util/osuser: run getent on non-Linux Unixes
Remove the restriction that getent is skipped on non-Linux unixes.
Improve validation of the parsed output from getent, in case unknown
systems return unusable information.

Fixes #12730.

Signed-off-by: Ross Williams <ross@ross-williams.net>
2024-07-26 14:25:46 -07:00
Andrea Gottardo
6840f471c0 net/dnsfallback: set CanPort80 in static DERPMap (#12929)
Updates tailscale/corp#21949

As discussed with @raggi, this PR updates the static DERPMap embedded in the client to reflect the availability of HTTP on the DERP servers run by Tailscale.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-07-26 13:04:12 -07:00
Andrea Gottardo
90be06bd5b health: introduce captive-portal-detected Warnable (#12707)
Updates tailscale/tailscale#1634

This PR introduces a new `captive-portal-detected` Warnable which is set to an unhealthy state whenever a captive portal is detected on the local network, preventing Tailscale from connecting.



ipn/ipnlocal: fix captive portal loop shutdown


Change-Id: I7cafdbce68463a16260091bcec1741501a070c95

net/captivedetection: fix mutex misuse

ipn/ipnlocal: ensure that we don't fail to start the timer


Change-Id: I3e43fb19264d793e8707c5031c0898e48e3e7465

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-07-26 11:25:55 -07:00
Brad Fitzpatrick
cf97cff33b wgengine/netstack: simplify netaddrIPFromNetstackIP
Updates #cleanup

Change-Id: I66878b08a75d44170460cbf33c895277c187bd8d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-25 20:05:16 -07:00
Paul Scott
855da47777 tsweb: Add MiddlewareStack func to apply lists of Middleware (#12907)
Fixes #12909

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-07-25 14:20:17 +01:00
Nick Khyl
43375c6efb types/lazy: re-init SyncValue during test cleanup if it wasn't set before SetForTest
Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-24 11:47:58 -05:00
Paul Scott
ba7f2d129e tsweb: log all cancellations as 499s (#12894)
Updates #12141

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-07-24 08:58:06 +01:00
Irbe Krumina
57856fc0d5 ipn,wgengine/magicsock: allow setting static node endpoints via tailscaled configfile (#12882)
wgengine/magicsock,ipn: allow setting static node endpoints via tailscaled config file.

Adds a new StaticEndpoints field to tailscaled config
that can be used to statically configure the endpoints
that the node advertizes. This field will replace
TS_DEBUG_PRETENDPOINTS env var that can be used to achieve the same.

Additionally adds some functionality that ensures that endpoints
are updated when configfile is reloaded.

Also, refactor configuring/reconfiguring components to use the
same functionality when configfile is parsed the first time or
subsequent times (after reload). Previously a configfile reload
did not result in resetting of prefs. Now it does- but does not yet
tell the relevant components to consume the new prefs. This is to
be done in a follow-up.

Updates tailscale/tailscale#12578


Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-23 16:50:55 +01:00
License Updater
9904421853 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-07-22 14:50:50 -07:00
Nick Khyl
5d09649b0b types/lazy: add (*SyncValue[T]).SetForTest method
It is sometimes necessary to change a global lazy.SyncValue for the duration of a test. This PR adds a (*SyncValue[T]).SetForTest method to facilitate that.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-22 15:10:31 -05:00
Nick Khyl
d500a92926 util/slicesx: add HasPrefix, HasSuffix, CutPrefix, and CutSuffix functions
The standard library includes these for strings and byte slices,
but it lacks similar functions for generic slices of comparable types.
Although they are not as commonly used, these functions are useful
in scenarios such as working with field index sequences (i.e., []int)
via reflection.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-22 11:03:46 -05:00
Flakes Updater
1f94047475 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-07-21 14:29:01 -07:00
Nick Khyl
bd54b61746 types/opt: add (Value[T]).GetOr(def T) T method
Updates #12736

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-19 15:50:24 -05:00
Nick Khyl
20562a4fb9 cmd/viewer, types/views, util/codegen: add viewer support for custom container types
This adds support for container-like types such as Container[T] that
don't explicitly specify a view type for T. Instead, a package implementing
a container type should also implement and export a ContainerView[T, V] type
and a ContainerViewOf(*Container[T]) ContainerView[T, V] function, which
returns a view for the specified container, inferring the element view type V
from the element type T.

Updates #12736

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-19 12:50:39 -05:00
Andrew Lytvynov
e7bf6e716b cmd/tailscale: add --min-validity flag to the cert command (#12822)
Some users run "tailscale cert" in a cron job to renew their
certificates on disk. The time until the next cron job run may be long
enough for the old cert to expire with our default heristics.

Add a `--min-validity` flag which ensures that the returned cert is
valid for at least the provided duration (unless it's longer than the
cert lifetime set by Let's Encrypt).

Updates #8725

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-07-19 09:35:22 -07:00
Lee Briggs
32ce18716b Add extra environment variables in deployment template (#12858)
Fixes #12857

Signed-off-by: Lee Briggs <lee@leebriggs.co.uk>
2024-07-19 06:52:27 -07:00
Irbe Krumina
0f57b9340b cmd/k8s-operator,tstest,go.{mod,sum}: remove fybrik.io/crdoc dependency (#12862)
Remove fybrik.io/crdoc dependency as it is causing issues for folks attempting
to vendor tailscale using GOPROXY=direct.
This means that the CRD API docs in ./k8s-operator/api.md will no longer
be generated- I am going to look at replacing it with another tool
in a follow-up.

Updates tailscale/tailscale#12859

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-19 14:17:28 +01:00
Paul Scott
b2c522ce95 tsweb: log cancelled requests as 499
Fixes #12860

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-07-19 11:30:38 +01:00
Adrian Dewhurst
54f58d1143 ipn/ipnlocal: add comment explaining auto exit node migration
Updates tailscale/corp#19681

Change-Id: I6d396780b058ff0fbea0e9e53100f04ef3b76339
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2024-07-18 16:48:43 -04:00
Mario Minardi
485018696a {tool,client}: bump node version (#12840)
Bump node version to latest lts on the 18.x line which is 18.20.4 at the time of writing.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2024-07-18 13:12:42 -06:00
Nick Khyl
1608831c33 wgengine/router: use quad-100 as the nexthop on Windows
Windows requires routes to have a nexthop. Routes created using the interface's local IP address or an unspecified IP address ("0.0.0.0" or "::") as the nexthop are considered on-link routes. Notably, Windows treats on-link subnet routes differently, reserving the last IP in the range as the broadcast IP and therefore prohibiting TCP connections to it, resulting in WSA error 10049: "The requested address is not valid in its context. This does not happen with single-host routes, such as routes to Tailscale IP addresses, but becomes a problem with advertised subnets when all IPs in the range should be reachable.

Before Windows 8, only routes created with an unspecified IP address were considered on-link, so our previous approach of using the interface's own IP as the nexthop likely worked on Windows 7.

This PR updates configureInterface to use the TailscaleServiceIP (100.100.100.100) and its IPv6 counterpart as the nexthop for subnet routes.

Fixes tailscale/support-escalations#57

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-18 10:08:29 -05:00
Brad Fitzpatrick
d3af54444c client/tailscale: document ACLTestFailureSummary.User field
And justify its legacy name.

Updates #1931

Change-Id: I3eff043679bf8f046aed6e2c4fb7592fe2e66514
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-18 08:02:49 -07:00
Paul Scott
d97cddd876 tsweb: swallow panics
With this change, the error handling and request logging are all done in defers
after calling inner.ServeHTTP. This ensures that any recovered values which we
want to re-panic with retain a useful stacktrace.  However, we now only
re-panic from errorHandler when there's no outside logHandler. Which if you're
using StdHandler there always is. We prefer this to ensure that we are able to
write a 500 Internal Server Error to the client. If a panic hits http.Server
then the response is not sent back.

Updates #12784

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-07-18 15:41:04 +01:00
Brad Fitzpatrick
f77821fd63 derp/derphttp: determine whether a region connect was to non-ideal node
... and then do approximately nothing with that information, other
than a big TODO. This is mostly me relearning this code and leaving
breadcrumbs for others in the future.

Updates #12724

Signed-off-by: Brad Fitzpatrick <brad@danga.com>
2024-07-17 14:59:45 -07:00
Brad Fitzpatrick
0b32adf9ec hostinfo: set Hostinfo.PackageType for mkctr container builds
Fixes tailscale/corp#21448

Change-Id: Id60fb5cd7d31ef94cdbb176141e034845a480a00
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-17 11:26:16 -07:00
Cameron Stokes
1ac14d7216 Dockerfile: remove warning (#12841)
Fixes tailscale/tailscale#12842

Signed-off-by: Cameron Stokes <cameron@cameronstokes.com>
2024-07-17 10:30:15 -07:00
Aaron Klotz
4ff276cf52 VERSION.txt: this is v1.71.0
Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-07-17 11:27:05 -06:00
Irbe Krumina
2742153f84 cmd/k8s-operator: add a metric to track the amount of ProxyClass resources (#12833)
Updates tailscale/tailscale#10709

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-17 14:34:56 +01:00
Paul Scott
646990a7d0 tsweb: log once per request
StdHandler/retHandler would previously emit one log line for each request.
If there were multiple StdHandler in the chain, there would be one log line
per instance of retHandler.

With this change, only the outermost StdHandler/logHandler actually logs the
request or invokes OnStart or OnCompletion callbacks. The error-rendering part
of retHandler lives on in errorHandler, and errorHandler passes those errors up
the stack to logHandler through a callback that logHandler places in the
request.Context().

Updates tailscale/corp#19999

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-07-16 15:52:23 +01:00
Adrian Dewhurst
8882c6b730 ipn/ipnlocal: wait for DERP before auto exit node migration
Updates tailscale/corp#19681

Change-Id: I31dec154aa3b5edba01f10eec37640f631729cb2
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2024-07-15 12:53:03 -04:00
License Updater
35d2efd692 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-07-15 08:44:32 -07:00
Anton Tolchanov
fc074a6b9f client/tailscale: add the nodeAttrs section
This change allows ACL contents to include node attributes
https://tailscale.com/kb/1337/acl-syntax#node-attributes-nodeattrs

Updates tailscale/corp#20583

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-07-15 16:43:48 +01:00
Paul Scott
014bf25c0a tsweb: fix TestStdHandler_panic flake
Fixes #12816

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-07-15 16:34:13 +01:00
Adrian Dewhurst
0834712c91 ipn: allow FQDN in exit node selection
To match the format of exit node suggestions and ensure that the result
is not ambiguous, relax exit node CLI selection to permit using a FQDN
including the trailing dot.

Updates #12618

Change-Id: I04b9b36d2743154aa42f2789149b2733f8555d3f
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2024-07-15 11:22:30 -04:00
Paul Scott
fec41e4904 tsweb: add stack trace to panic error msg
Updates #12784

Signed-off-by: Paul Scott <paul@tailscale.com>
2024-07-15 10:34:13 +01:00
Nick Khyl
fd0acc4faf cmd/cloner, cmd/viewer: add _test prefix for files generated with the test build tag
Updates #12736

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-12 15:31:34 -05:00
Fran Bull
380a3a0834 appc: track metrics for route info storing
Track how often we're writing state and how many routes we're writing.

Updates #11008

Signed-off-by: Fran Bull <fran@tailscale.com>
2024-07-12 10:39:48 -07:00
Anton Tolchanov
5d61d1c7b0 log/sockstatlog: don't block for more than 5s on shutdown
Fixes tailscale/corp#21618

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-07-12 17:50:11 +01:00
Linus Brogan
9609b26541 cmd/tailscale: resolve taildrive share paths
Fixes #12258.

Signed-off-by: Linus Brogan <git@linusbrogan.com>
2024-07-12 11:47:48 -05:00
Anton Tolchanov
7403d8e9a8 logtail: close idle HTTP connections on shutdown
Fixes tailscale/corp#21609

Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-07-12 17:47:30 +01:00
Jordan Whited
f0b9d3f477 net/tstun: fix docstring for Wrapper.SetWGConfig (#12796)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-07-12 09:28:35 -07:00
Andrea Gottardo
3f3edeec07 health: drop unnecessary logging in TestSetUnhealthyWithTimeToVisible (#12795)
Fixes tailscale/tailscale#12794

We were printing some leftover debug logs within a callback function that would be executed after the test completion, causing the test to fail. This change drops the log calls to address the issue.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-07-12 16:05:27 +00:00
Brad Fitzpatrick
808b4139ee wgengine/magicsock: use wireguard-go/conn.PeerAwareEndpoint
If we get an non-disco presumably-wireguard-encrypted UDP packet from
an IP:port we don't recognize, rather than drop the packet, give it to
WireGuard anyway and let WireGuard try to figure out who it's from and
tell us.

This uses the new hook added in https://github.com/tailscale/wireguard-go/pull/27

Updates tailscale/corp#20732

Change-Id: I5c61a40143810592f9efac6c12808a87f924ecf2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-12 08:24:06 -07:00
Claire Wang
49bf63cdd0 ipn/ipnlocal: check for offline auto exit node in SetControlClientStatus (#12772)
Updates tailscale/corp#19681

Signed-off-by: Claire Wang <claire@tailscale.com>
2024-07-12 11:06:07 -04:00
Joe Tsai
d209b032ab syncs: add Map.WithLock to allow mutations to the underlying map (#8101)
Some operations cannot be implemented with the prior API:
* Iterating over the map and deleting keys
* Iterating over the map and replacing items
* Calling APIs that expect a native Go map

Add a Map.WithLock method that acquires a write-lock on the map
and then calls a user-provided closure with the underlying Go map.
This allows users to interact with the Map as a regular Go map,
but with the gaurantees that it is concurrent safe.

Updates tailscale/corp#9115

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2024-07-11 16:16:30 -07:00
Nick Khyl
fc28c8e7f3 cmd/cloner, cmd/viewer, util/codegen: add support for generic types and interfaces
This adds support for generic types and interfaces to our cloner and viewer codegens.
It updates these packages to determine whether to make shallow or deep copies based
on the type parameter constraints. Additionally, if a template parameter or an interface
type has View() and Clone() methods, we'll use them for getters and the cloner of the
owning structure.

Updates #12736

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-11 16:38:53 -05:00
Andrea Gottardo
b7c3cfe049 health: support delayed Warnable visibility (#12783)
Updates tailscale/tailscale#4136

To reduce the likelihood of presenting spurious warnings, add the ability to delay the visibility of certain Warnables, based on a TimeToVisible time.Duration field on each Warnable. The default is zero, meaning that a Warnable is immediately visible to the user when it enters an unhealthy state.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-07-11 18:51:47 +00:00
KevinLiang10
8d7b78f3f7 net/dns/publicdns: remove additional information in DOH URL passed to IPv6 address generation for controlD.
This commit truncates any additional information (mainly hostnames) that's passed to controlD via DOH URL in DoHIPsOfBase.
This change is to make sure only resolverID is passed to controlDv6Gen but not the additional information.

Updates: #7946
Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
2024-07-10 16:14:05 -04:00
Mario Minardi
041733d3d1 publicapi: add note that API docs have moved to existing docs files (#12770)
Add note that API docs have moved to `https://tailscale.com/api` to the
top of existing API docs markdown files.

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

Signed-off-by: Mario Minardi <mario@tailscale.com>
2024-07-10 12:42:34 -06:00
Anton Tolchanov
874972b683 posture: add network hardware addresses to posture identity
If an optional `hwaddrs` URL parameter is present, add network interface
hardware addresses to the posture identity response.

Just like with serial numbers, this requires client opt-in via MDM or
`tailscale set --posture-checking=true`
(https://tailscale.com/kb/1326/device-identity)

Updates tailscale/corp#21371

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-07-10 18:28:30 +01:00
Lee Briggs
b546a6e758 wgengine/magicsock: allow a CSV list for pretendpoint
Load Balancers often have more than one ingress IP, so allowing us to
add multiple means we can offer multiple options.

Updates #12578

Change-Id: I4aa49a698d457627d2f7011796d665c67d4c7952
Signed-off-by: Lee Briggs <lee@leebriggs.co.uk>
2024-07-10 09:57:28 -07:00
Brad Fitzpatrick
c6af5bbfe8 all: add test for package comments, fix, add comments as needed
Updates #cleanup

Change-Id: Ic4304e909d2131a95a38b26911f49e7b1729aaef
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-10 09:57:00 -07:00
Joe Tsai
e92f4c6af8 syncs: add generic Pool (#12759)
Pool is a type-safe wrapper over sync.Pool.

Updates tailscale/corp#11038
Updates #cleanup

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2024-07-10 09:39:52 -07:00
Irbe Krumina
986d60a094 cmd/k8s-operator: add metrics for attempted/uploaded session recordings (#12765)
Updates tailscale/corp#19821

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-10 14:00:42 +01:00
Irbe Krumina
6a982faa7d cmd/k8s-operator: send container name to session recorder (#12763)
Updates tailscale/corp#19821

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-07-10 10:48:53 +01:00
Anton Tolchanov
c8f258a904 prober: propagate DERPMap request creation errors
Updates tailscale/corp#8497

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-07-09 13:43:51 +01:00
Nick Khyl
726d5d507d cmd/k8s-operator: update depaware.txt
This fixes an issue caused by the merge order of 2b638f550d and 8bd442ba8c.

Updates #Cleanup

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-08 23:02:27 -05:00
Maisem Ali
2238ca8a05 go.mod: bump bart
Updates #bart

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-07-08 19:10:44 -07:00
Nick Khyl
8bd442ba8c util/winutil/gp, net/dns: add package for Group Policy API
This adds a package with GP-related functions and types to be used in the future PRs.
It also updates nrptRuleDatabase to use the new package instead of its own gpNotificationWatcher implementation.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-08 20:37:03 -05:00
Andrew Lytvynov
7b1c764088 ipn/ipnlocal: gate systemd-run flags on systemd version (#12747)
We added a workaround for --wait, but didn't confirm the other flags,
which were added in systemd 235 and 236. Check systemd version for
deciding when to set all 3 flags.

Fixes #12136

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-07-08 16:40:06 -07:00
Andrew Lytvynov
b8af91403d clientupdate: return true for CanAutoUpdate for macsys (#12746)
While `clientupdate.Updater` won't be able to apply updates on macsys,
we use `clientupdate.CanAutoUpdate` to gate the EditPrefs endpoint in
localAPI. We should allow the GUI client to set AutoUpdate.Apply on
macsys for it to properly get reported to the control plane. This also
allows the tailnet-wide default for auto-updates to propagate to macsys
clients.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-07-08 15:54:50 -07:00
Nick Khyl
e21d8768f9 types/opt: add generic Value[T any] for optional values of any types
Updates #12736

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-07-08 17:00:43 -05:00
Maisem Ali
5576972261 client/tailscale: use safesocket.ConnectContext
I apparently missed this in 4b6a0c42c8.

Updates tailscale/corp#18266

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-07-08 13:59:41 -07:00
Irbe Krumina
ba517ab388 cmd/k8s-operator,ssh/tailssh,tsnet: optionally record 'kubectl exec' sessions via Kubernetes operator's API server proxy (#12274)
cmd/k8s-operator,ssh/tailssh,tsnet: optionally record kubectl exec sessions

The Kubernetes operator's API server proxy, when it receives a request
for 'kubectl exec' session now reads 'RecorderAddrs', 'EnforceRecorder'
fields from tailcfg.KubernetesCapRule.
If 'RecorderAddrs' is set to one or more addresses (of a tsrecorder instance(s)),
it attempts to connect to those and sends the session contents
to the recorder before forwarding the request to the kube API
server. If connection cannot be established or fails midway,
it is only allowed if 'EnforceRecorder' is not true (fail open).

Updates tailscale/corp#19821

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Maisem Ali <maisem@tailscale.com>
2024-07-08 21:18:55 +01:00
Maisem Ali
2b638f550d cmd/k8s-operator: add depaware.txt
Updates #12742

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-07-08 12:43:10 -07:00
License Updater
9102a5bb73 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-07-08 11:19:29 -05:00
Flakes Updater
c8fe9f0064 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-07-08 11:19:06 -05:00
Brad Fitzpatrick
42dac7c5c2 wgengine/magicsock: add debug envknob for injecting an endpoint
For testing. Lee wants to play with 'AWS Global Accelerator Custom
Routing with Amazon Elastic Kubernetes Service'. If this works well
enough, we can promote it.

Updates #12578

Change-Id: I5018347ed46c15c9709910717d27305d0aedf8f4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-08 07:59:40 -07:00
Brad Fitzpatrick
d2fef01206 control/controlknobs,tailcfg,wgengine/magicsock: remove DRPO shutoff switch
The DERP Return Path Optimization (DRPO) is over four years old (and
on by default for over two) and we haven't had problems, so time to
remove the emergency shutoff code (controlknob) which we've never
used. The controlknobs are only meant for new features, to mitigate
risk. But we don't want to keep them forever, as they kinda pollute
the code.

Updates #150

Change-Id: If021bc8fd1b51006d8bddd1ffab639bb1abb0ad1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-06 19:50:53 -07:00
Brad Fitzpatrick
9df107f4f0 wgengine/magicsock: use derp-region-as-magic-AddrPort hack in fewer places
And fix up a bogus comment and flesh out some other comments.

Updates #cleanup

Change-Id: Ia60a1c04b0f5e44e8d9587914af819df8e8f442a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-06 19:38:59 -07:00
Aaron Klotz
e181f12a7b util/winutil/s4u: fix some doc comments in the s4u package
This is #cleanup

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-07-05 13:19:47 -07:00
Brad Fitzpatrick
c4b20c5411 go.mod: bump github.com/tailscale/wireguard-go
Updates tailscale/corp#20732

Change-Id: Ic0272fe9a226afef4e23dfca5da8cd1d550c1cd6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-05 09:44:15 -07:00
Tom Proctor
01a7726cf7 cmd/containerboot,cmd/k8s-operator: enable IPv6 for fqdn egress proxies (#12577)
cmd/containerboot,cmd/k8s-operator: enable IPv6 for fqdn egress proxies

Don't skip installing egress forwarding rules for IPv6 (as long as the host
supports IPv6), and set headless services `ipFamilyPolicy` to
`PreferDualStack` to optionally enable both IP families when possible. Note
that even with `PreferDualStack` set, testing a dual-stack GKE cluster with
the default DNS setup of kube-dns did not correctly set both A and
AAAA records for the headless service, and instead only did so when
switching the cluster DNS to Cloud DNS. For both IPv4 and IPv6 to work
simultaneously in a dual-stack cluster, we require headless services to
return both A and AAAA records.

If the host doesn't support IPv6 but the FQDN specified only has IPv6
addresses available, containerboot will exit with error code 1 and an
error message because there is no viable egress route.

Fixes #12215

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-07-05 12:21:48 +01:00
Andrea Gottardo
309afa53cf health: send ImpactsConnectivity value over LocalAPI (#12700)
Updates tailscale/tailscale#4136

We should make sure to send the value of ImpactsConnectivity over to the clients using LocalAPI as they need it to display alerts in the GUI properly.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-07-03 20:19:06 +00:00
Charlotte Brandhorst-Satzkorn
42f01afe26 cmd/tailscale/cli: exit node filter should display all exit node options (#12699)
This change expands the `exit-node list -filter` command to display all
location based exit nodes for the filtered country. This allows users
to switch to alternative servers when our recommended exit node is not
working as intended.

This change also makes the country filter matching case insensitive,
e.g. both USA and usa will work.

Updates #12698

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2024-07-03 11:48:20 -07:00
Chris Palmer
59936e6d4a scripts: don't refresh the pacman repository on Arch (#12194)
Fixes #12186

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
Co-authored-by: Chris Palmer <cpalmer@tailscale.com>
2024-07-03 09:58:01 -07:00
Andrea Gottardo
732af2f6e0 health: reduce severity of some warnings, improve update messages (#12689)
Updates tailscale/tailscale#4136

High severity health warning = a system notification will appear, which can be quite disruptive to the user and cause unnecessary concern in the event of a temporary network issue.

Per design decision (@sonovawolf), the severity of all warnings but "network is down" should be tuned down to medium/low. ImpactsConnectivity should be set, to change the icon to an exclamation mark in some cases, but without a notification bubble.

I also tweaked the messaging for update-available, to reflect how each platform gets updates in different ways.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-07-02 23:11:28 -07:00
Andrew Lytvynov
458decdeb0 go.toolchain.rev: update to Go 1.22.5 (#12690)
Updates https://github.com/tailscale/corp/issues/21304

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-07-02 14:39:30 -07:00
Jonathan Nobels
4e5ef5b628 net/dns: fix broken dns benchmark tests (#12686)
Updates tailscale/corp#20677

The recover function wasn't getting set in the benchmark
tests.  Default changed to an empty func.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2024-07-02 14:22:13 -04:00
Flakes Updater
012933635b go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-07-01 16:58:27 -07:00
Brad Fitzpatrick
da32468988 version/mkversion: allow env config of oss git cache dir
Updates tailscale/corp#21262

Change-Id: I80bd880b53f6d851c15479f39fad62b25f1095f1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-01 16:22:55 -07:00
Jordan Whited
ddf94a7b39 cmd/stunstamp: fix handling of invalid DERP map resp (#12679)
Updates tailscale/corp#20344

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-07-01 16:07:48 -07:00
Brad Fitzpatrick
b56058d7e3 tool/gocross: fix regression detecting when gocross needs rebuild
Fix regression from #8108 (Mar 2023). Since that change, gocross has
always been rebuilt on each run of ./tool/go (gocross-wrapper.sh),
adding ~100ms.  (Well, not totally rebuilt; cmd/go's caching still
ends up working fine.)

The problem was $gocross_path was just "gocross", which isn't in my
path (and "." isn't in my $PATH, as it shouldn't be), so this line was
always evaluating to the empty string:

    gotver="$($gocross_path gocross-version 2>/dev/null || echo '')"

The ./gocross is fine because of the earlier `cd "$repo_root"`

Updates tailscale/corp#21262
Updates tailscale/corp#21263

Change-Id: I80d25446097a3bb3423490c164352f0b569add5f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-07-01 14:40:51 -07:00
License Updater
d780755340 licenses: update license notices
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2024-07-01 10:31:21 -07:00
Percy Wegmann
489b990240 tailcfg: bump CurrentCapabilityVersion to capture SSH agent forwarding fix
Updates #12467

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-07-01 11:57:55 -05:00
Tom Proctor
d15250aae9 go.{mod,sum}: bump mkctr (#12654)
go get github.com/tailscale/mkctr@main

Pulls in changes to support a local target that only pushes
a single-platform image to the machine's local image store.

Fixes tailscale/mkctr#18

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-07-01 10:23:46 +01:00
Claire Wang
8965e87fa8 ipn/ipnlocal: handle auto value for ExitNodeID syspolicy (#12512)
Updates tailscale/corp#19681

Signed-off-by: Claire Wang <claire@tailscale.com>
2024-06-28 23:17:31 -04:00
James Tucker
114d1caf55 derp/xdp: retain the link so that the fd is not closed
BPF links require that the owning FD remains open, this FD is embedded
into the RawLink returned by the attach function and must live for the
duration of the server.

Updates ENG-4274

Signed-off-by: James Tucker <james@tailscale.com>
2024-06-28 14:38:21 -07:00
James Tucker
b565a9faa7 cmd/xdpderper: add autodetection for default interface name
This makes deployment easier in hetrogenous environments.

Updates ENG-4274

Signed-off-by: James Tucker <james@tailscale.com>
2024-06-27 15:42:11 -07:00
Anton Tolchanov
781f79408d ipn/ipnlocal: allow multiple signature chains from the same SigCredential
Detection of duplicate Network Lock signature chains added in
01847e0123 failed to account for chains
originating with a SigCredential signature, which is used for wrapped
auth keys. This results in erroneous removal of signatures that
originate from the same re-usable auth key.

This change ensures that multiple nodes created by the same re-usable
auth key are not getting filtered out by the network lock.

Updates tailscale/corp#19764

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-06-27 19:28:57 +01:00
Anton Tolchanov
4651827f20 tka: test SigCredential signatures and netmap filtering
This change moves handling of wrapped auth keys to the `tka` package and
adds a test covering auth key originating signatures (SigCredential) in
netmap.

Updates tailscale/corp#19764

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-06-27 19:28:57 +01:00
Adrian Dewhurst
8f7588900a ipn/ipnlocal: fix nil pointer dereference and add related test
Fixes #12644

Change-Id: I3589b01a9c671937192caaedbb1312fd906ca712
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2024-06-27 14:21:59 -04:00
Jordan Whited
0bb82561ba go.mod: update wireguard-go (#12645)
This pulls in device.WaitPool fixes from tailscale/wireguard-go@1e08883
and tailscale/wireguard-go@cfa4567.

Updates tailscale/corp#21095

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-06-27 10:32:14 -07:00
Andrew Lytvynov
2064dc20d4 health,ipn/ipnlocal: hide update warning when auto-updates are enabled (#12631)
When auto-udpates are enabled, we don't need to nag users to update
after a new release, before we release auto-updates.

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

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-06-27 09:36:29 -07:00
Anton Tolchanov
23c5870bd3 tsnet: do not log an error on shutdown
Updates tailscale/corp#20583

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-06-27 13:28:19 +01:00
Josh McKinney
18939df0a7 fix: broken tests for localhost
Signed-off-by: Josh McKinney <joshka@users.noreply.github.com>
2024-06-26 20:57:19 -07:00
Josh McKinney
1d6ab9f9db cmd/serve: don't convert localhost to 127.0.0.1
This is not valid in many situations, specifically when running a local astro site that listens on localhost, but ignores 127.0.0.1

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

Signed-off-by: Josh McKinney <joshka@users.noreply.github.com>
2024-06-26 20:57:19 -07:00
Brad Fitzpatrick
210264f942 cmd/derper: clarify that derper and tailscaled need to be in sync
Fixes #12617

Change-Id: Ifc87b7d9cf699635087afb57febd01fb9a6d11b7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-26 19:46:42 -07:00
Brad Fitzpatrick
6b801a8e9e cmd/derper: link to various derper docs in more places
In hopes it'll be found more.

Updates tailscale/corp#20844

Change-Id: Ic92ee9908f45b88f8770de285f838333f9467465
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-26 19:46:35 -07:00
Flakes Updater
b3f91845dc go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-06-26 19:43:06 -07:00
James Tucker
46fda6bf4c cmd/derper: add some DERP diagnostics pointers
A few other minor language updates.

Updates tailscale/corp#20844

Change-Id: Idba85941baa0e2714688cc8a4ec3e242e7d1a362
Signed-off-by: James Tucker <james@tailscale.com>
2024-06-26 19:18:28 -07:00
Brad Fitzpatrick
9766f0e110 net/dns: move mutex before the field it guards
And some misc doc tweaks for idiomatic Go style.

Updates #cleanup

Change-Id: I3ca45f78aaca037f433538b847fd6a9571a2d918
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-26 16:56:02 -07:00
dependabot[bot]
94defc4056 build(deps): bump golang.org/x/image from 0.15.0 to 0.18.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.15.0 to 0.18.0.
- [Commits](https://github.com/golang/image/compare/v0.15.0...v0.18.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-26 16:19:35 -07:00
Aaron Klotz
b292f7f9ac util/winutil/s4u: fix incorrect token type specified in s4u Login
This was correct before, I think I just made a copy/paste error when
updating that PR.

Updates #12383

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-26 14:28:56 -06:00
Aaron Klotz
5f177090e3 util/winutil: ensure domain controller address is used when retrieving remote profile information
We cannot directly pass a flat domain name into NetUserGetInfo; we must
resolve the address of a domain controller first.

This PR implements the appropriate resolution mechanisms to do that, and
also exposes a couple of new utility APIs for future needs.

Fixes #12627

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-26 13:10:10 -06:00
Andrew Dunham
0323dd01b2 ci: enable checklocks workflow for specific packages
This turns the checklocks workflow into a real check, and adds
annotations to a few basic packages as a starting point.

Updates #12625

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I2b0185bae05a843b5257980fc6bde732b1bdd93f
2024-06-26 13:55:07 -04:00
Andrew Dunham
8487fd2ec2 wgengine/magicsock: add more DERP home clientmetrics
Updates tailscale/corp#18095

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I423adca2de0730092394bb5fd5796cd35557d352
2024-06-26 11:44:26 -04:00
Adrian Dewhurst
a6b13e6972 cmd/tailscale/cli: correct command emitted by exit node suggestion
The exit node suggestion CLI command was written with the assumption
that it's possible to provide a stableid on the command line, but this
is incorrect. Instead, it will now emit the name of the exit node.

Fixes #12618

Change-Id: Id7277f395b5fca090a99b0d13bfee7b215bc9802
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2024-06-26 11:29:14 -04:00
Naman Sood
75254178a0 ipn/ipnlocal: don't bind localListener if its context is canceled (#12621)
The context can get canceled during backoff, and binding after that
makes the listener impossible to close afterwards.

Fixes #12620.

Signed-off-by: Naman Sood <mail@nsood.in>
2024-06-26 11:18:45 -04:00
Anton Tolchanov
787ead835f tsweb: accept a function to call before request handling
To complement the existing `onCompletion` callback, which is called
after request handler.

Updates tailscale/corp#17075

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2024-06-26 11:27:26 +01:00
Andrea Gottardo
6e55d8f6a1 health: add warming-up warnable (#12553) 2024-06-25 22:02:38 -07:00
Andrew Dunham
30f8d8199a ipn/ipnlocal: fix data race in tests
We can observe a data race in tests when logging after a test is
finished. `b.onHealthChange` is called in a goroutine after being
registered with `health.Tracker.RegisterWatcher`, which calls callbacks
in `setUnhealthyLocked` in a new goroutine.

See: https://github.com/tailscale/tailscale/actions/runs/9672919302/job/26686038740

Updates #12054

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ibf22cc994965d88a9e7236544878d5373f91229e
2024-06-25 21:43:22 -07:00
Aaron Klotz
da078b4c09 util/winutil: add package for logging into Windows via Service-for-User (S4U)
This PR ties together pseudoconsoles, user profiles, s4u logons, and
process creation into what is (hopefully) a simple API for various
Tailscale services to obtain Windows access tokens without requiring
knowledge of any Windows passwords. It works both for domain-joined
machines (Kerberos) and non-domain-joined machines. The former case
is fairly straightforward as it is fully documented. OTOH, the latter
case is not documented, though it is fully defined in the C headers in
the Windows SDK. The documentation blanks were filled in by reading
the source code of Microsoft's Win32 port of OpenSSH.

We need to do a bit of acrobatics to make conpty work correctly while
creating a child process with an s4u token; see the doc comments above
startProcessInternal for details.

Updates #12383

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-25 22:05:52 -06:00
Andrew Dunham
53a5d00fff net/dns: ensure /etc/resolv.conf is world-readable even with a umask
Previously, if we had a umask set (e.g. 0027) that prevented creating a
world-readable file, /etc/resolv.conf would be created without the o+r
bit and thus other users may be unable to resolve DNS.

Since a umask only applies to file creation, chmod the file after
creation and before renaming it to ensure that it has the appropriate
permissions.

Updates #12609

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I2a05d64f4f3a8ee8683a70be17a7da0e70933137
2024-06-26 00:02:05 -04:00
Andrew Dunham
8161024176 wgengine/magicsock: always set home DERP if no control conn
The logic we added in #11378 would prevent selecting a home DERP if we
have no control connection.

Updates tailscale/corp#18095

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I44bb6ac4393989444e4961b8cfa27dc149a33c6e
2024-06-25 23:31:14 -04:00
Andrew Dunham
a475c435ec net/dns/resolver: fix test failure
Updates #cleanup

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I0e815a69ee44ca0ff7c0ea0ca3c6904bbf67ed1f
2024-06-25 23:08:08 -04:00
Jonathan Nobels
27033c6277 net/dns: recheck DNS config on SERVFAIL errors (#12547)
Fixes tailscale/corp#20677

Replaces the original attempt to rectify this (by injecting a netMon
event) which was both heavy handed, and missed cases where the
netMon event was "minor".

On apple platforms, the fetching the interface's nameservers can
and does return an empty list in certain situations.   Apple's API
in particular is very limiting here.  The header hints at notifications
for dns changes which would let us react ahead of time, but it's all
private APIs.

To avoid remaining in the state where we end up with no
nameservers but we absolutely need them, we'll react
to a lack of upstream nameservers by attempting to re-query
the OS.

We'll rate limit this to space out the attempts.   It seems relatively
harmless to attempt a reconfig every 5 seconds (triggered
by an incoming query) if the network is in this broken state.

Missing nameservers might possibly be a persistent condition
(vs a transient error), but that would  also imply that something
out of our control is badly misconfigured.

Tested by randomly returning [] for the nameservers.   When switching
between Wifi networks, or cell->wifi, this will randomly trigger
the bug, and we appear to reliably heal the DNS state.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2024-06-25 14:56:13 -04:00
Brad Fitzpatrick
d5e692f7e7 ipn/ipnlocal: check operator user via osuser package
So non-local users (e.g. Kerberos on FreeIPA) on Linux can be looked
up. Our default binaries are built with pure Go os/user which only
supports the classic /etc/passwd and not any libc-hooked lookups.

Updates #12601

Change-Id: I9592db89e6ca58bf972f2dcee7a35fbf44608a4f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-25 10:56:32 -07:00
Jordan Whited
94415e8029 cmd/stunstamp: remove sqlite DB and API (#12604)
stunstamp now sends data to Prometheus via remote write, and Prometheus
can serve the same data. Retaining and cleaning up old data in sqlite
leads to long probing pauses, and it's not worth investing more effort
to optimize the schema and/or concurrency model.

Updates tailscale/corp#20344

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-06-25 10:21:40 -07:00
Brad Fitzpatrick
3485e4bf5a derp: make RunConnectionLoop funcs take Messages, support PeerPresentFlags
PeerPresentFlags was added in 5ffb2668ef but wasn't plumbed through to
the RunConnectionLoop. Rather than add yet another parameter (as
IP:port was added earlier), pass in the raw PeerPresentMessage and
PeerGoneMessage struct values, which are the same things, plus two
fields: PeerGoneReasonType for gone and the PeerPresentFlags from
5ffb2668ef.

Updates tailscale/corp#17816

Change-Id: Ib19d9f95353651ada90656071fc3656cf58b7987
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-25 09:47:25 -07:00
Fran Bull
7eb8a77ac8 appc: don't schedule advertisement of 0 routes
When the store-appc-routes flag is on for a tailnet we are writing the
routes more often than seems necessary. Investigation reveals that we
are doing so ~every time we observe a dns response, even if this causes
us not to advertise any new routes. So when we have no new routes,
instead do not advertise routes.

Fixes #12593

Signed-off-by: Fran Bull <fran@tailscale.com>
2024-06-25 08:12:51 -07:00
Irbe Krumina
24a40f54d9 util/linuxfw: verify that IPv6 if available if (#12598)
nftable runner for an IPv6 address gets requested.

Updates tailscale/tailscale#12215

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-06-25 14:13:49 +01:00
Brad Fitzpatrick
d91e5c25ce derp: redo, simplify how mesh update writes are queued/written
I couldn't convince myself the old way was safe and couldn't lose
writes.

And it seemed too complicated.

Updates tailscale/corp#21104

Change-Id: I17ba7c7d6fd83458a311ac671146a1f6a458a5c1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-24 21:42:14 -07:00
Brad Fitzpatrick
ded7734c36 derp: account for increased size of peerPresent messages in mesh updates
sendMeshUpdates tries to write as much as possible without blocking,
being careful to check the bufio.Writer.Available size before writes.

Except that regressed in 6c791f7d60 which made those messages larger, which
meants we were doing network I/O with the Server mutex held.

Updates tailscale/corp#13945

Change-Id: Ic327071d2e37de262931b9b390cae32084811919
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-24 16:21:01 -07:00
Andrew Dunham
200d92121f types/lazy: add Peek method to SyncValue
This adds the ability to "peek" at the value of a SyncValue, so that
it's possible to observe a value without computing this.

Updates tailscale/corp#17122

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Co-authored-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I06f88c22a1f7ffcbc7ff82946335356bb0ef4622
2024-06-24 12:41:00 -07:00
Aaron Klotz
7dd76c3411 net/netns: add Windows support for bind-to-interface-by-route
This is implemented via GetBestInterfaceEx. Should we encounter errors
or fail to resolve a valid, non-Tailscale interface, we fall back to
returning the index for the default interface instead.

Fixes #12551

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-24 10:43:34 -06:00
tailscale-license-updater[bot]
591979b95f licenses: update license notices (#12414)
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
Co-authored-by: License Updater <noreply+license-updater@tailscale.com>
2024-06-24 09:20:34 -07:00
Brad Fitzpatrick
91786ff958 cmd/derper: add debug endpoint to adjust mutex profiling rate
Updates #3560

Change-Id: I474421ce75c79fb66e1c306ed47daebc5a0e069e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-24 09:05:31 -07:00
Brad Fitzpatrick
5ffb2668ef derp: add PeerPresentFlags bitmask to Watch messages
Updates tailscale/corp#17816

Change-Id: Ib5baf6c981a6a4c279f8bbfef02048cfbfb3323b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-22 20:38:25 -07:00
Aaron Klotz
d7a4f9d31c net/dns: ensure multiple hosts with the same IP address are combined into a single HostEntry
This ensures that each line has a unique IP address.

Fixes #11939

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-21 13:16:49 -06:00
Jordan Whited
0d6e71df70 cmd/stunstamp: add explicit metric to track timeout events (#12564)
Timeouts could already be identified as NaN values on
stunstamp_derp_stun_rtt_ns, but we can't use NaN effectively with
promql to visualize them. So, this commit adds a timeouts metric that
we can use with rate/delta/etc promql functions.

Updates tailscale/corp#20689

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-06-21 09:17:35 -07:00
Kristoffer Dalby
dcb0f189cc cmd/proxy-to-grafana: add flag for alternative control server
Fixes #12571

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-06-21 12:17:39 +02:00
Brad Fitzpatrick
5ec01bf3ce wgengine/filter: support FilterRules matching on srcIP node caps [capver 100]
See #12542 for background.

Updates #12542

Change-Id: Ida312f700affc00d17681dc7551ee9672eeb1789
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-20 12:27:04 -07:00
Irbe Krumina
07063bc5c7 ssh/tailssh: fix integration test (#12562)
Updates#cleanup

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-06-20 19:30:19 +01:00
Brad Fitzpatrick
fd3efd9bad control/controlclient: add more Screen Time blocking detection
Updates #9658
Updates #12545

Change-Id: Iec1dad354a75f145567b4055d77b1c1db27c89e2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@gottardo.me>
2024-06-20 11:09:50 -07:00
Keli
bd50a3457d wgengine/filter: add "Accept" TCP log lines to verbose logging (#12525)
Changes "Accept" TCP logs to display in verbose logs only,
and removes lines from default logging behavior.

Updates #12158

Signed-off-by: Keli Velazquez <keli@tailscale.com>
2024-06-20 13:24:46 -04:00
Percy Wegmann
730f0368d0 ssh/tailssh: replace incubator process with su instead of running su as child
This allows the SSH_AUTH_SOCK environment variable to work inside of
su and agent forwarding to succeed.

Fixes #12467

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-06-20 10:11:03 -05:00
Andrew Dunham
24976b5bfd cmd/tailscale/cli: actually perform Noise request in 'debug ts2021'
This actually performs a Noise request in the 'debug ts2021' command,
instead of just exiting once we've dialed a connection. This can help
debug certain forms of captive portals and deep packet inspection that
will allow a connection, but will RST the connection when trying to send
data on the post-upgraded TCP connection.

Updates #1634

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I1e46ca9c9a0751c55f16373a6a76cdc24fec1f18
2024-06-19 19:56:20 -04:00
Andrew Dunham
732605f961 control/controlclient: move noiseConn to internal package
So that it can be later used in the 'tailscale debug ts2021' function in
the CLI, to aid in debugging captive portals/WAFs/etc.

Updates #1634

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Iec9423f5e7570f2c2c8218d27fc0902137e73909
2024-06-19 19:56:20 -04:00
Brad Fitzpatrick
0004827681 control/controlhttp: add health warning for macOS filtering blocking Tailscale (#12546)
Updates #9658
Updates #12545

Change-Id: I6612b9b65eb193a1a651e219b5198c7c20ed94e1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
2024-06-19 13:22:14 -07:00
Brad Fitzpatrick
1023b2a82c util/deephash: fix test regression on 32-bit
Fix regression from bd93c3067e where I didn't notice the
32-bit test failure was real and not its usual slowness-related
regression. Yay failure blindness.

Updates #12526

Change-Id: I00e33bba697e2cdb61a0d76a71b62406f6c2eeb9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-19 12:25:53 -07:00
Andrea Gottardo
d7619d273b health: fix nil DERPMap dereference panic
Looks like a DERPmap might not be available when we try to get the
name associated with a region ID, and that was causing an intermittent
panic in CI.

Fixes #12534

Change-Id: I4ace53681bf004df46c728cff830b27339254243
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-06-19 12:20:44 -07:00
Brad Fitzpatrick
25eeafde23 derp: don't verify mesh peers when --verify-clients is set
Updates tailscale/corp#20654

Change-Id: I33c7ca3c7a3c4e492797b73c66eefb699376402c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-19 08:59:34 -07:00
Brad Fitzpatrick
4b39b6f7ce derp: fix fmt verb for nodekeys
It was hex-ifying the String() form of key.NodePublic, which was already hex.
I noticed in some logs:

    "client 6e6f64656b65793a353537353..."

And thought that 6x6x6x6x looked strange. It's "nodekey:" in hex.

Updates tailscale/corp#20844

Change-Id: Ib9f2d63b37e324420b86efaa680668a9b807e465
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-19 08:40:47 -07:00
Brad Fitzpatrick
21460a5b14 tailcfg, wgengine/filter: remove most FilterRule.SrcBits code
The control plane hasn't sent it to clients in ages.

Updates tailscale/corp#20965

Change-Id: I1d71a4b6dd3f75010a05c544ee39827837c30772
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-18 21:45:22 -07:00
Brad Fitzpatrick
162d593514 net/flowtrack: fix, test String method
I meant to do this in the earlier change and had a git fail.

To atone, add a test too while I'm here.

Updates #12486
Updates #12507

Change-Id: I4943b454a2530cb5047636f37136aa2898d2ffc7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-18 21:44:44 -07:00
Brad Fitzpatrick
9e0a5cc551 net/flowtrack: optimize Tuple type for use as map key
This gets UDP filter overhead closer to TCP. Still ~2x, but no longer ~3x.

    goos: darwin
    goarch: arm64
    pkg: tailscale.com/wgengine/filter
                                       │   before    │                after                │
                                       │   sec/op    │   sec/op     vs base                │
    FilterMatch/tcp-not-syn-v4-8         15.43n ± 3%   15.38n ± 5%        ~ (p=0.339 n=10)
    FilterMatch/udp-existing-flow-v4-8   42.45n ± 0%   34.77n ± 1%  -18.08% (p=0.000 n=10)
    geomean                              25.59n        23.12n        -9.65%

Updates #12486

Change-Id: I595cfadcc6b7234604bed9c4dd4261e087c0d4c4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-18 21:31:48 -07:00
Andrea Gottardo
d6a8fb20e7 health: include DERP region name in bad derp notifications (#12530)
Fixes tailscale/corp#20971

We added some Warnables for DERP failure situations, but their Text currently spits out the DERP region ID ("10") in the UI, which is super ugly. It would be better to provide the RegionName of the DERP region that is failing. We can do so by storing a reference to the last-known DERP map in the health package whenever we fetch one, and using it when generating the notification text.

This way, the following message...

> Tailscale could not connect to the relay server '10'. The server might be temporarily unavailable, or your Internet connection might be down.

becomes:

> Tailscale could not connect to the 'Seattle' relay server. The server might be temporarily unavailable, or your Internet connection might be down.

which is a lot more user-friendly.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-06-18 16:03:17 -07:00
Andrea Gottardo
8eb15d3d2d cli/netcheck: fail with output if we time out fetching a derpmap (#12528)
Updates tailscale/corp#20969

Right now, when netcheck starts, it asks tailscaled for a copy of the DERPMap. If it doesn't have one, it makes a HTTPS request to controlplane.tailscale.com to fetch one.

This will always fail if you're on a network with a captive portal actively blocking HTTPS traffic. The code appears to hang entirely because the http.Client doesn't have a Timeout set. It just sits there waiting until the request succeeds or fails.

This adds a timeout of 10 seconds, and logs more details about the status of the HTTPS request.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-06-18 15:04:43 -07:00
Jordan Whited
a93173b56a cmd/xdpderper,derp/xdp: implement mode that drops STUN packets (#12527)
This is useful during maintenance as a method for shedding home client
load.

Updates tailscale/corp#20689

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-06-18 14:06:00 -07:00
Andrea Gottardo
d55b105dae health: expose DependsOn to local API via UnhealthyState (#12513)
Updates #4136

Small PR to expose the health Warnables dependencies to the GUI via LocalAPI, so that we can only show warnings for root cause issues, and filter out unnecessary messages before user presentation.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-06-18 13:34:55 -07:00
Brad Fitzpatrick
bd93c3067e wgengine/filter/filtertype: make Match.IPProto a view
I noticed we were allocating these every time when they could just
share the same memory. Rather than document ownership, just lock it
down with a view.

I was considering doing all of the fields but decided to just do this
one first as test to see how infectious it became.  Conclusion: not
very.

Updates #cleanup (while working towards tailscale/corp#20514)

Change-Id: I8ce08519de0c9a53f20292adfbecd970fe362de0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-18 13:30:55 -07:00
Flakes Updater
bfb775ce62 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2024-06-18 11:26:57 -07:00
Tom Proctor
3099323976 cmd/k8s-operator,k8s-operator,go.{mod,sum}: publish proxy status condition for annotated services (#12463)
Adds a new TailscaleProxyReady condition type for use in corev1.Service
conditions.

Also switch our CRDs to use metav1.Condition instead of
ConnectorCondition. The Go structs are seralized identically, but it
updates some descriptions and validation rules. Update k8s
controller-tools and controller-runtime deps to fix the documentation
generation for metav1.Condition so that it excludes comments and
TODOs.

Stop expecting the fake client to populate TypeMeta in tests. See
kubernetes-sigs/controller-runtime#2633 for details of the change.

Finally, make some minor improvements to validation for service hostnames.

Fixes #12216

Co-authored-by: Irbe Krumina <irbe@tailscale.com>
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2024-06-18 19:01:40 +01:00
Andrew Dunham
45d2f4301f proxymap, various: distinguish between different protocols
Previously, we were registering TCP and UDP connections in the same map,
which could result in erroneously removing a mapping if one of the two
connections completes while the other one is still active.

Add a "proto string" argument to these functions to avoid this.
Additionally, take the "proto" argument in LocalAPI, and plumb that
through from the CLI and add a new LocalClient method.

Updates tailscale/corp#20600

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I35d5efaefdfbf4721e315b8ca123f0c8af9125fb
2024-06-18 13:29:41 -04:00
Aaron Klotz
2cb408f9b1 hostinfo: update Windows hostinfo to include MSIDist registry value
We need to expand our enviornment information to include info about
the Windows store. Thinking about future plans, it would be nice
to include both the packaging mechanism and the distribution mechanism.

In this PR we change packageTypeWindows to check a new registry value
named MSIDist, and concatenate that value to "msi/" when present.

We also remove vestigial NSIS detection.

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

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-18 10:19:00 -06:00
James Tucker
87c5ad4c2c derp: add a verifyClients check to the consistency check
Only implemented for the local tailscaled variant for now.

Updates tailscale/corp#20844

Signed-off-by: James Tucker <james@tailscale.com>
2024-06-17 16:22:48 -07:00
Joe Tsai
2db2d04a37 types/logid: add Add method (#12478)
The Add method derives a new ID by adding a signed integer
to the ID, treating it as an unsigned 256-bit big-endian integer.

We also add Less and Compare methods to PrivateID to provide
feature parity with existing methods on PublicID.

Updates tailscale/corp#11038

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2024-06-17 16:03:44 -07:00
Jordan Whited
315f3d5df1 derp/xdp: fix handling of zero value UDP checksums (#12510)
validate_udp_checksum was previously indeterminate (not zero) at
declaration, and IPv4 zero value UDP checksum packets were being passed
to the kernel.

Updates tailscale/corp#20689

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-06-17 14:06:53 -07:00
Irbe Krumina
8cc2738609 cmd/{containerboot,k8s-operator}: store proxy device ID early to help with cleanup for broken proxies (#12425)
* cmd/containerboot: store device ID before setting up proxy routes.

For containerboot instances whose state needs to be stored
in a Kubernetes Secret, we additonally store the device's
ID, FQDN and IPs.
This is used, between other, by the Kubernetes operator,
who uses the ID to delete the device when resources need
cleaning up and writes the FQDN and IPs on various kube
resource statuses for visibility.

This change shifts storing device ID earlier in the proxy setup flow,
to ensure that if proxy routing setup fails,
the device can still be deleted.

Updates tailscale/tailscale#12146

Signed-off-by: Irbe Krumina <irbe@tailscale.com>

* code review feedback

Signed-off-by: Irbe Krumina <irbe@tailscale.com>

---------

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-06-17 18:50:50 +01:00
Andrew Lytvynov
674c998e93 cmd/tailscale/cli: do not allow update --version on macOS (#12508)
We do not support specific version updates or track switching on macOS.
Do not populate the flag to avoid confusion.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-06-17 10:33:26 -07:00
Andrew Lytvynov
be54dde0eb clientupdate: allow switching from unstable to stable tracks (#12477)
Previously, we would only compare the current version to resolved latest
version for track. When running `tailscale update --track=stable` from
an unstable build, it would almost always fail because the stable
version is "older". But we should support explicitly switching tracks
like that.

Fixes #12347

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2024-06-17 10:23:27 -07:00
Kristoffer Dalby
a1ab7f7c94 client/tailscale: add NodeID to device
Updates tailscale/corp#20514

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-06-17 17:06:18 +02:00
Brad Fitzpatrick
1f6645b19f net/ipset: skip the loop over Prefixes when there's only one
For pprof cosmetic/confusion reasons more than performance, but it
might have tiny speed benefit.

Updates #12486

Change-Id: I40e03714f3afa3a7e7f5e1fa99b81c7e889b91b6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-17 06:05:36 -07:00
Brad Fitzpatrick
20a5f939ba wgengine/filter: add UDP flow benchmark
To show the effects of the flow LRU accounting on e.g. QUIC traffic.

For an open TCP connection:

    BenchmarkFilterMatch/tcp-not-syn-v4-8           66602070                16.74 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           67718179                16.60 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           68403351                16.84 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           66076416                16.87 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           67159012                16.67 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           65009526                16.58 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           66588055                16.62 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           63037071                16.58 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           69124975                21.15 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           54482922                20.41 ns/op

And an open UDP connection:

    BenchmarkFilterMatch/udp-existing-flow-v4-8             25570020                44.09 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             26725958                46.99 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             25936412                47.11 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             25418325                45.99 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             25759848                44.73 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             25212488                46.26 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             25344370                44.55 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             26399372                45.26 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             26274159                47.51 ns/op
    BenchmarkFilterMatch/udp-existing-flow-v4-8             26070472                46.79 ns/op

Updates #12486

Change-Id: Ica4263fb77972cf43db5a2e9433b4429506edfde
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-16 20:04:50 -07:00
Brad Fitzpatrick
bf2d13cfa0 net/ipset: return all closures from named wrappers
So profiles show more useful names than just func1, func2, func3, etc.
There will still be func1 on them all, but the symbol before will say
what the lookup type is.

Updates #12486

Change-Id: I910b024a7861394eb83d07f5a899eae338cb1f22
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-16 15:37:04 -07:00
Brad Fitzpatrick
86e0f9b912 net/ipset, wgengine/filter/filtertype: add split-out packages
This moves NewContainsIPFunc from tsaddr to new ipset package.

And wgengine/filter types gets split into wgengine/filter/filtertype,
so netmap (and thus the CLI, etc) doesn't need to bring in ipset,
bart, etc.

Then add a test making sure the CLI deps don't regress.

Updates #1278

Change-Id: Ia246d6d9502bbefbdeacc4aef1bed9c8b24f54d5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-16 15:25:23 -07:00
Brad Fitzpatrick
36b1b4af2f wgengine/filter: split local+logging lookups by IPv4-vs-IPv6
If we already know it's an incoming IPv4 packet, no need to match
against the set of IPv6s and vice versa.

    goos: darwin
    goarch: arm64
    pkg: tailscale.com/wgengine/filter
                                         │   before    │                after                │
                                         │   sec/op    │   sec/op     vs base                │
    FilterMatch/not-local-v4-8             21.40n ± 3%   16.04n ± 1%  -25.09% (p=0.000 n=10)
    FilterMatch/not-local-v6-8             20.75n ± 9%   15.71n ± 0%  -24.31% (p=0.000 n=10)
    FilterMatch/no-match-v4-8              81.37n ± 1%   78.57n ± 3%   -3.43% (p=0.005 n=10)
    FilterMatch/no-match-v6-8              77.73n ± 2%   73.71n ± 3%   -5.18% (p=0.002 n=10)
    FilterMatch/tcp-not-syn-v4-8           21.41n ± 3%   16.86n ± 0%  -21.25% (p=0.000 n=10)
    FilterMatch/tcp-not-syn-v4-no-logs-8   10.04n ± 0%   10.05n ± 0%        ~ (p=0.446 n=10)
    geomean                                29.07n        25.05n       -13.84%

Updates #12486

Change-Id: I70e5024af03893327d26629a994ab2aa9811f4f3
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-16 10:57:01 -07:00
Brad Fitzpatrick
d4220a76da wgengine/filter: add TCP non-SYN benchmarks
To show performance during heavy flows on established connections.

    BenchmarkFilterMatch/tcp-not-syn-v4-8           52125848                21.46 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           52388781                21.43 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           52916954                21.32 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           52590730                21.43 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-8           53015923                21.32 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-no-logs-8   122795029                9.783 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-no-logs-8   100000000               10.09 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-no-logs-8   120090948                9.747 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-no-logs-8   122350448               10.55 ns/op
    BenchmarkFilterMatch/tcp-not-syn-v4-no-logs-8   122943025                9.813 ns/op

Updates #12486

Change-Id: I8e7c9380bf969ad646851d53f8a4c287717694ea
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-16 09:22:10 -07:00
Brad Fitzpatrick
10e8a2a05c wgengine/filter: fix copy/pasteo in new benchmark's v6 CIDR
I noticed the not-local-v6 numbers were nowhere near the v4 numbers
(they should be identical) and then saw this. It meant the
Addr().Next() wasn't picking an IP that was no longer local, as
assumed.

Updates #12486

Change-Id: I18dfb641f00c74c6252666bc41bd2248df15fadd
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-16 08:39:02 -07:00
Brad Fitzpatrick
64ac64fb66 net/tsaddr: use bart in NewContainsIPFunc, add tests, benchmarks
NewContainsIPFunc was previously documented as performing poorly if
there were many netip.Prefixes to search over. As such, we never it used it
in such cases.

This updates it to use bart at a certain threshold (over 6 prefixes,
currently), at which point the bart lookup overhead pays off.

This is currently kinda useless because we're not using it. But now we
can and get wins elsewhere. And we can remove the caveat in the docs.

    goos: darwin
    goarch: arm64
    pkg: tailscale.com/net/tsaddr
                                     │    before    │                after                 │
                                     │    sec/op    │    sec/op     vs base                │
    NewContainsIPFunc/empty-8          2.215n ± 11%   2.239n ±  1%   +1.08% (p=0.022 n=10)
    NewContainsIPFunc/cidr-list-1-8    17.44n ±  0%   17.59n ±  6%   +0.89% (p=0.000 n=10)
    NewContainsIPFunc/cidr-list-2-8    27.85n ±  0%   28.13n ±  1%   +1.01% (p=0.000 n=10)
    NewContainsIPFunc/cidr-list-3-8    36.05n ±  0%   36.56n ± 13%   +1.41% (p=0.000 n=10)
    NewContainsIPFunc/cidr-list-4-8    43.73n ±  0%   44.38n ±  1%   +1.50% (p=0.000 n=10)
    NewContainsIPFunc/cidr-list-5-8    51.61n ±  2%   51.75n ±  0%        ~ (p=0.101 n=10)
    NewContainsIPFunc/cidr-list-10-8   95.65n ±  0%   68.92n ±  0%  -27.94% (p=0.000 n=10)
    NewContainsIPFunc/one-ip-8         4.466n ±  0%   4.469n ±  1%        ~ (p=0.491 n=10)
    NewContainsIPFunc/two-ip-8         8.002n ±  1%   7.997n ±  4%        ~ (p=0.697 n=10)
    NewContainsIPFunc/three-ip-8       27.98n ±  1%   27.75n ±  0%   -0.82% (p=0.012 n=10)
    geomean                            19.60n         19.07n         -2.71%

Updates #12486

Change-Id: I2e2320cc4384f875f41721374da536bab995c1ce
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-16 08:38:45 -07:00
Maisem Ali
491483d599 cmd/viewer,type/views: add MapSlice for maps of slices
This abstraction provides a nicer way to work with
maps of slices without having to write out three long type
params.

This also allows it to provide an AsMap implementation which
copies the map and the slices at least.

Updates tailscale/corp#20910

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2024-06-15 22:24:29 -07:00
Brad Fitzpatrick
7574f586aa wgengine/filter: add more benchmarks, make names more explicit
Updates #12486

Change-Id: If2e6d9c70212644eb4a0bc8ec6768512894a646a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-15 22:02:19 -07:00
Brad Fitzpatrick
21ed31e33a wgengine/filter: use NewContainsIPFunc for Srcs matches
NewContainsIPFunc returns a contains matcher optimized for its
input. Use that instead of what this did before, always doing a test
over each of a list of netip.Prefixes.

    goos: darwin
    goarch: arm64
    pkg: tailscale.com/wgengine/filter
                        │   before    │                after                │
                        │   sec/op    │   sec/op     vs base                │
    FilterMatch/file1-8   32.60n ± 1%   18.87n ± 1%  -42.12% (p=0.000 n=10)

Updates #12486

Change-Id: I8f902bc064effb431e5b46751115942104ff6531
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-15 21:15:03 -07:00
Brad Fitzpatrick
e2c0d69c9c wgengine/filter: add filter benchmark
Baseline, on 2020 M1 Macbook Pro, on power:

    goos: darwin
    goarch: arm64
    pkg: tailscale.com/wgengine/filter
    BenchmarkFilterMatch/file1-8    34089133                32.79 ns/op
    BenchmarkFilterMatch/file1-8    35423917                32.59 ns/op
    BenchmarkFilterMatch/file1-8    35208598                32.80 ns/op
    BenchmarkFilterMatch/file1-8    35180470                33.39 ns/op
    BenchmarkFilterMatch/file1-8    36671608                32.82 ns/op
    BenchmarkFilterMatch/file1-8    35435991                33.13 ns/op
    BenchmarkFilterMatch/file1-8    34689181                33.29 ns/op
    BenchmarkFilterMatch/file1-8    34786053                32.94 ns/op
    BenchmarkFilterMatch/file1-8    35366235                32.56 ns/op
    BenchmarkFilterMatch/file1-8    35342799                32.47 ns/op

Updates #12486

Change-Id: I8f902bc064effb431e5b46751115942104ff6531
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-15 20:47:34 -07:00
Brad Fitzpatrick
7bc9d453c2 health: fix data race in new warnable code
Fixes #12479

Change-Id: Ice84d5eb12d835eeddf6fc8cc337ea6b4dddcf6c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-14 21:44:23 -07:00
Nick Khyl
c32efd9118 various: create a catch-all NRPT rule when "Override local DNS" is enabled on Windows
Without this rule, Windows 8.1 and newer devices issue parallel DNS requests to DNS servers
associated with all network adapters, even when "Override local DNS" is enabled and/or
a Mullvad exit node is being used, resulting in DNS leaks.

This also adds "disable-local-dns-override-via-nrpt" nodeAttr that can be used to disable
the new behavior if needed.

Fixes tailscale/corp#20718

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2024-06-14 14:41:50 -05:00
Aaron Klotz
7354547bd8 util/winutil: update UserProfile to ensure any environment variables in the roaming profile path are expanded
Updates #12383

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-14 13:01:01 -06:00
Andrea Gottardo
a8ee83e2c5 health: begin work to use structured health warnings instead of strings, pipe changes into ipn.Notify (#12406)
Updates tailscale/tailscale#4136

This PR is the first round of work to move from encoding health warnings as strings and use structured data instead. The current health package revolves around the idea of Subsystems. Each subsystem can have (or not have) a Go error associated with it. The overall health of the backend is given by the concatenation of all these errors.

This PR polishes the concept of Warnable introduced by @bradfitz a few weeks ago. Each Warnable is a component of the backend (for instance, things like 'dns' or 'magicsock' are Warnables). Each Warnable has a unique identifying code. A Warnable is an entity we can warn the user about, by setting (or unsetting) a WarningState for it. Warnables have:

- an identifying Code, so that the GUI can track them as their WarningStates come and go
- a Title, which the GUIs can use to tell the user what component of the backend is broken
- a Text, which is a function that is called with a set of Args to generate a more detailed error message to explain the unhappy state

Additionally, this PR also begins to send Warnables and their WarningStates through LocalAPI to the clients, using ipn.Notify messages. An ipn.Notify is only issued when a warning is added or removed from the Tracker.

In a next PR, we'll get rid of subsystems entirely, and we'll start using structured warnings for all errors affecting the backend functionality.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-06-14 11:53:56 -07:00
Andrea Gottardo
e8ca30a5c7 xcode/iOS: support serial number collection via MDM on iOS (#11429)
Fixes tailscale/corp#18366.

This PR provides serial number collection on iOS, by allowing system administrators to pass a `DeviceSerialNumber` MDM key which can be read by the `posture` package in Go.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2024-06-14 10:59:40 -07:00
Aaron Klotz
bd2a6d5386 util/winutil: add UserProfile type for (un)loading user profiles
S4U logons do not automatically load the associated user profile. In this
PR we add UserProfile to handle that part. Windows docs indicate that
we should try to resolve a remote profile path when present, so we attempt
to do so when the local computer is joined to a domain.

Updates #12383

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-14 11:02:01 -06:00
Jordan Whited
9189fe007b cmd/stunc: support user-specified port (#12469)
Updates tailscale/corp#20689

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-06-14 09:46:16 -07:00
James Tucker
85ad0c276c tailcfg: update PeerAPIDNS Port value documentation
We do not intend to use this value for feature support communication in
the future, and have applied changes elsewhere that now fix the expected
value.

Updates tailscale/corp#19391
Updates tailscale/corp#20398

Signed-off-by: James Tucker <james@tailscale.com>
2024-06-14 09:05:18 -07:00
Jordan Whited
65888d95c9 derp/xdp,cmd/xdpderper: initial skeleton (#12390)
This commit introduces a userspace program for managing an experimental
eBPF XDP STUN server program. derp/xdp contains the eBPF pseudo-C along
with a Go pkg for loading it and exporting its metrics.
cmd/xdpderper is a package main user of derp/xdp.

Updates tailscale/corp#20689

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2024-06-14 08:45:24 -07:00
Brad Fitzpatrick
6908fb0de3 ipn/localapi,client/tailscale,cmd/derper: add WhoIs lookup by nodekey, use in derper
Fixes #12465

Change-Id: I9b7c87315a3d2b2ecae2b8db9e94b4f5a1eef74a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-14 08:37:38 -07:00
Andrew Dunham
72c8f7700b wgengine/netstack: add test for #12448
This refactors the logic for determining whether a packet should be sent
to the host or not into a function, and then adds tests for it.

Updates #11304
Updates #12448

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: Ief9afa98eaffae00e21ceb7db073c61b170355e5
2024-06-13 11:46:48 -07:00
Irbe Krumina
88f2d234a4 wgengine/netstack: fix 4via6 subnet routes (#12454)
Fix a bug where, for a subnet router that advertizes
4via6 route, all packets with a source IP matching
the 4via6 address were being sent to the host itself.
Instead, only send to host packets whose destination
address is host's local address.

Fixes tailscale/tailscale#12448

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Co-authored-by: Andrew Dunham <andrew@du.nham.ca>
2024-06-13 17:31:45 +01:00
Brad Fitzpatrick
ccdd2e6650 cmd/derper: add a README
Updates tailscale/corp#20844

Change-Id: Ie3ca5dd7f582f4f298339dd3cd2039243c204ef8
Co-authored-by: James Tucker <james@tailscale.com>
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Co-authored-by: Andrew Dunham <andrew@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-06-12 20:12:41 -07:00
Percy Wegmann
d7fdc01f7f ssh/tailssh: check IsSELinuxEnforcing in tailscaled process
Checking in the incubator as this used to do fails because
the getenforce command is not on the PATH.

Updates #12442

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2024-06-12 20:29:48 -05:00
Jonathan Nobels
02e3c046aa net/dns: re-query system resolvers on no-upstream resolver failure on apple platforms (#12398)
Fixes tailscale/corp#20677

On macOS sleep/wake, we're encountering a condition where reconfigure the network
a little bit too quickly - before apple has set the nameservers for our interface.
This results in a persistent condition where we have no upstream resolver and
fail all forwarded DNS queries.

No upstream nameservers is a legitimate configuration, and we have no  (good) way
of determining when Apple is ready - but if we need to forward a query, and we
have no nameservers, then something has gone badly wrong and the network is
very broken.

A simple fix here is to simply inject a netMon event, which will go through the
configuration dance again when we hit the SERVFAIL condition.

Tested by artificially/randomly returning [] for the list of nameservers in the bespoke
ipn-bridge code responsible for getting the nameservers.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2024-06-12 15:45:13 -04:00
Andrew Dunham
d0f1a838a6 net/dnscache: use parent context to perform lookup
As an alterative to #11935 using #12003.

Updates #11935

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I05f643fe812ceeaec5f266e78e3e529cab3a1ac3
2024-06-12 11:21:02 -07:00
Mario Minardi
5f121396e9 VERSION.txt: this is v1.69.0 (#12441)
Signed-off-by: Mario Minardi <mario@tailscale.com>
2024-06-12 11:16:33 -06:00
461 changed files with 44421 additions and 8791 deletions

View File

@@ -24,5 +24,11 @@ jobs:
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
- name: Run checklocks vet
# TODO: remove || true once we have applied checklocks annotations everywhere.
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true
# TODO(#12625): add more packages as we add annotations
run: |-
./tool/go vet -vettool=/tmp/checklocks \
./envknob \
./ipn/store/mem \
./net/stun/stuntest \
./net/wsconn \
./proxymap

View File

@@ -67,6 +67,11 @@ jobs:
image: ${{ matrix.image }}
options: --user root
steps:
- name: install dependencies (pacman)
# Refresh the package databases to ensure that the tailscale package is
# defined.
run: pacman -Sy
if: contains(matrix.image, 'archlinux')
- name: install dependencies (yum)
# tar and gzip are needed by the actions/checkout below.
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}

View File

@@ -0,0 +1,23 @@
# Run the ssh integration tests with `make sshintegrationtest`.
# These tests can also be running locally.
name: "ssh-integrationtest"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
pull_request:
paths:
- "ssh/**"
- "tempfork/gliderlabs/ssh/**"
- ".github/workflows/ssh-integrationtest"
jobs:
ssh-integrationtest:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Run SSH integration tests
run: |
make sshintegrationtest

View File

@@ -194,7 +194,7 @@ jobs:
- name: chown
run: chown -R $(id -u):$(id -g) $PWD
- name: privileged tests
run: ./tool/go test ./util/linuxfw
run: ./tool/go test ./util/linuxfw ./derp/xdp
vm:
runs-on: ["self-hosted", "linux", "vm"]
@@ -480,7 +480,7 @@ jobs:
uses: actions/checkout@v4
- name: check that 'go generate' is clean
run: |
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator')
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
./tool/go generate $pkgs
echo
echo

View File

@@ -1,17 +1,13 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
############################################################################
# Note that this Dockerfile is currently NOT used to build any of the published
# Tailscale container images and may have drifted from the image build mechanism
# we use.
# Tailscale images are currently built using https://github.com/tailscale/mkctr,
# and the build script can be found in ./build_docker.sh.
#
# WARNING: Tailscale is not yet officially supported in container
# environments, such as Docker and Kubernetes. Though it should work, we
# don't regularly test it, and we know there are some feature limitations.
#
# See current bugs tagged "containers":
# https://github.com/tailscale/tailscale/labels/containers
#
############################################################################
# This Dockerfile includes all the tailscale binaries.
#
# To build the Dockerfile:
@@ -46,7 +42,7 @@ RUN go install \
gvisor.dev/gvisor/pkg/tcpip/stack \
golang.org/x/crypto/ssh \
golang.org/x/crypto/acme \
nhooyr.io/websocket \
github.com/coder/websocket \
github.com/mdlayher/netlink
COPY . .

View File

@@ -21,6 +21,7 @@ updatedeps: ## Update depaware deps
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper \
tailscale.com/cmd/k8s-operator \
tailscale.com/cmd/stund
depaware: ## Run depaware checks
@@ -30,6 +31,7 @@ depaware: ## Run depaware checks
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper \
tailscale.com/cmd/k8s-operator \
tailscale.com/cmd/stund
buildwindows: ## Build tailscale CLI for windows/amd64
@@ -110,8 +112,8 @@ publishdevnameserver: ## Build and publish k8s-nameserver image to location spec
.PHONY: sshintegrationtest
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
@GOOS=linux GOARCH=amd64 go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
GOOS=linux GOARCH=amd64 go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \

View File

@@ -1 +1 @@
1.67.0
1.72.0

3
api.md
View File

@@ -1,3 +1,6 @@
> [!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)**.

View File

@@ -11,6 +11,7 @@ package appc
import (
"context"
"fmt"
"net/netip"
"slices"
"strings"
@@ -21,6 +22,7 @@ import (
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/dnsname"
"tailscale.com/util/execqueue"
"tailscale.com/util/mak"
@@ -78,6 +80,42 @@ type RouteAdvertiser interface {
UnadvertiseRoute(...netip.Prefix) error
}
var (
metricStoreRoutesRateBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000}
metricStoreRoutesNBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000, 10000}
metricStoreRoutesRate []*clientmetric.Metric
metricStoreRoutesN []*clientmetric.Metric
)
func initMetricStoreRoutes() {
for _, n := range metricStoreRoutesRateBuckets {
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_rate_%d", n)))
}
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter("appc_store_routes_rate_over"))
for _, n := range metricStoreRoutesNBuckets {
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_n_routes_%d", n)))
}
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter("appc_store_routes_n_routes_over"))
}
func recordMetric(val int64, buckets []int64, metrics []*clientmetric.Metric) {
if len(buckets) < 1 {
return
}
// finds the first bucket where val <=, or len(buckets) if none match
// for bucket values of 1, 10, 100; 0-1 goes to [0], 2-10 goes to [1], 11-100 goes to [2], 101+ goes to [3]
bucket, _ := slices.BinarySearch(buckets, val)
metrics[bucket].Add(1)
}
func metricStoreRoutes(rate, nRoutes int64) {
if len(metricStoreRoutesRate) == 0 {
initMetricStoreRoutes()
}
recordMetric(rate, metricStoreRoutesRateBuckets, metricStoreRoutesRate)
recordMetric(nRoutes, metricStoreRoutesNBuckets, metricStoreRoutesN)
}
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
// so that we can know, even after a restart, which routes came from ACLs and which were
// learned from domains.
@@ -141,6 +179,7 @@ func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInf
}
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
metricStoreRoutes(c, l)
})
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
@@ -442,8 +481,10 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
}
}
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
e.scheduleAdvertisement(domain, toAdvertise...)
if len(toAdvertise) > 0 {
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
e.scheduleAdvertisement(domain, toAdvertise...)
}
}
}

View File

@@ -15,6 +15,7 @@ import (
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/appc/appctest"
"tailscale.com/tstest"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
"tailscale.com/util/must"
)
@@ -569,3 +570,35 @@ func TestRateLogger(t *testing.T) {
t.Fatalf("wasCalled: got false, want true")
}
}
func TestRouteStoreMetrics(t *testing.T) {
metricStoreRoutes(1, 1)
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
metricStoreRoutes(6, 6) // the 10 buckets value should be 1
metricStoreRoutes(10001, 10001) // the over buckets value should be 1
wanted := map[string]int64{
"appc_store_routes_n_routes_1": 2,
"appc_store_routes_rate_1": 2,
"appc_store_routes_n_routes_5": 1,
"appc_store_routes_rate_5": 1,
"appc_store_routes_n_routes_10": 1,
"appc_store_routes_rate_10": 1,
"appc_store_routes_n_routes_over": 1,
"appc_store_routes_rate_over": 1,
}
for _, x := range clientmetric.Metrics() {
if x.Value() != wanted[x.Name()] {
t.Errorf("%s: want: %d, got: %d", x.Name(), wanted[x.Name()], x.Value())
}
}
}
func TestMetricBucketsAreSorted(t *testing.T) {
if !slices.IsSorted(metricStoreRoutesRateBuckets) {
t.Errorf("metricStoreRoutesRateBuckets must be in order")
}
if !slices.IsSorted(metricStoreRoutesNBuckets) {
t.Errorf("metricStoreRoutesNBuckets must be in order")
}
}

View File

@@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package appctest contains code to help test App Connectors.
package appctest
import (

View File

@@ -1,21 +1,11 @@
#!/usr/bin/env sh
#
# Runs `go build` with flags configured for docker distribution. All
# it does differently from `go build` is burn git commit and version
# information into the binaries inside docker, so that we can track down user
# issues.
#
############################################################################
#
# WARNING: Tailscale is not yet officially supported in container
# environments, such as Docker and Kubernetes. Though it should work, we
# don't regularly test it, and we know there are some feature limitations.
#
# See current bugs tagged "containers":
# https://github.com/tailscale/tailscale/labels/containers
#
############################################################################
# This script builds Tailscale container images using
# github.com/tailscale/mkctr.
# By default the images will be tagged with the current version and git
# hash of this repository as produced by ./cmd/mkversion.
# This is the image build mechanim used to build the official Tailscale
# container images.
set -eu
@@ -49,7 +39,7 @@ case "$TARGET" in
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
--base="${BASE}" \
--tags="${TAGS}" \
--gotags="ts_kube" \
--gotags="ts_kube,ts_package_container" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \

View File

@@ -37,6 +37,16 @@ type ACLTest struct {
Allow []string `json:"allow,omitempty"` // old name for accept
}
// NodeAttrGrant defines additional string attributes that apply to specific devices.
type NodeAttrGrant struct {
// Target specifies which nodes the attributes apply to. The nodes can be a
// tag (tag:server), user (alice@example.com), group (group:kids), or *.
Target []string `json:"target,omitempty"`
// Attr are the attributes to set on Target(s).
Attr []string `json:"attr,omitempty"`
}
// ACLDetails contains all the details for an ACL.
type ACLDetails struct {
Tests []ACLTest `json:"tests,omitempty"`
@@ -44,6 +54,7 @@ type ACLDetails struct {
Groups map[string][]string `json:"groups,omitempty"`
TagOwners map[string][]string `json:"tagowners,omitempty"`
Hosts map[string]string `json:"hosts,omitempty"`
NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty"`
}
// ACL contains an ACLDetails and metadata.
@@ -150,7 +161,12 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
// ACLTestFailureSummary specifies the JSON format sent to the
// JavaScript client to be rendered in the HTML.
type ACLTestFailureSummary struct {
User string `json:"user,omitempty"`
// User is the source ("src") value of the ACL test that failed.
// The name "user" is a legacy holdover from the original naming and
// is kept for compatibility but it may also contain any value
// that's valid in a ACL test "src" field.
User string `json:"user,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
@@ -270,6 +286,9 @@ type UserRuleMatch struct {
Users []string `json:"users"`
Ports []string `json:"ports"`
LineNumber int `json:"lineNumber"`
// Via is the list of targets through which Users can access Ports.
// See https://tailscale.com/kb/1378/via for more information.
Via []string `json:"via,omitempty"`
// Postures is a list of posture policies that are
// associated with this match. The rules can be looked

View File

@@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
@@ -39,6 +40,7 @@ type Device struct {
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
Addresses []string `json:"addresses"`
DeviceID string `json:"id"`
NodeID string `json:"nodeId"`
User string `json:"user"`
Name string `json:"name"`
Hostname string `json:"hostname"`
@@ -213,6 +215,9 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
if err != nil {
return err
}
log.Printf("RESP: %di, path: %s", resp.StatusCode, path)
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {

View File

@@ -69,6 +69,14 @@ type LocalClient struct {
// connecting to the GUI client variants.
UseSocketOnly bool
// OmitAuth, if true, omits sending the local Tailscale daemon any
// authentication token that might be required by the platform.
//
// As of 2024-08-12, only macOS uses an authentication token. OmitAuth is
// meant for when Dial is set and the LocalAPI is being proxied to a
// different operating system, such as in integration tests.
OmitAuth bool
// tsClient does HTTP requests to the local Tailscale daemon.
// It's lazily initialized on first use.
tsClient *http.Client
@@ -103,7 +111,7 @@ func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string)
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
}
}
return safesocket.Connect(lc.socket())
return safesocket.ConnectContext(ctx, lc.socket())
}
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
@@ -124,8 +132,10 @@ func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error)
},
}
})
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
req.SetBasicAuth("", token)
if !lc.OmitAuth {
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
req.SetBasicAuth("", token)
}
}
return lc.tsClient.Do(req)
}
@@ -253,11 +263,16 @@ func (lc *LocalClient) sendWithHeaders(
}
if res.StatusCode != wantStatus {
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
return nil, nil, bestError(err, slurp)
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
}
return slurp, res.Header, nil
}
type httpStatusError struct {
error
HTTPStatus int
}
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
return lc.send(ctx, "GET", path, 200, nil)
}
@@ -278,9 +293,50 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
//
// If not found, the error is ErrPeerNotFound.
//
// For connections proxied by tailscaled, this looks up the owner of the given
// address as TCP first, falling back to UDP; if you want to only check a
// specific address family, use WhoIsProto.
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// ErrPeerNotFound is returned by WhoIs and WhoIsNodeKey when a peer is not found.
var ErrPeerNotFound = errors.New("peer not found")
// WhoIsNodeKey returns the owner of the given wireguard public key.
//
// If not found, the error is ErrPeerNotFound.
func (lc *LocalClient) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// WhoIsProto returns the owner of the remoteAddr, which must be an IP or
// IP:port, for the given protocol (tcp or udp).
//
// If not found, the error is ErrPeerNotFound.
func (lc *LocalClient) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
@@ -887,7 +943,20 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
//
// API maturity: this is considered a stable API.
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
return lc.CertPairWithValidity(ctx, domain, 0)
}
// CertPairWithValidity returns a cert and private key for the provided DNS
// domain.
//
// It returns a cached certificate from disk if it's still valid.
// When minValidity is non-zero, the returned certificate will be valid for at
// least the given duration, if permitted by the CA. If the certificate is
// valid, but for less than minValidity, it will be synchronously renewed.
//
// API maturity: this is considered a stable API.
func (lc *LocalClient) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
if err != nil {
return nil, nil, err
}

View File

@@ -6,9 +6,14 @@
package tailscale
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"tailscale.com/tstest/deptest"
"tailscale.com/types/key"
)
func TestGetServeConfigFromJSON(t *testing.T) {
@@ -30,6 +35,32 @@ func TestGetServeConfigFromJSON(t *testing.T) {
}
}
func TestWhoIsPeerNotFound(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer ts.Close()
lc := &LocalClient{
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
var std net.Dialer
return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String())
},
}
var k key.NodePublic
if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil {
t.Fatal(err)
}
res, err := lc.WhoIsNodeKey(context.Background(), k)
if err != ErrPeerNotFound {
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
}
res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678")
if err != ErrPeerNotFound {
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
}
}
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{

View File

@@ -3,7 +3,7 @@
"version": "0.0.1",
"license": "BSD-3-Clause",
"engines": {
"node": "18.16.1",
"node": "18.20.4",
"yarn": "1.22.19"
},
"type": "module",

View File

@@ -37,11 +37,18 @@ import (
)
const (
CurrentTrack = ""
StableTrack = "stable"
UnstableTrack = "unstable"
)
var CurrentTrack = func() string {
if version.IsUnstableBuild() {
return UnstableTrack
} else {
return StableTrack
}
}()
func versionToTrack(v string) (string, error) {
_, rest, ok := strings.Cut(v, ".")
if !ok {
@@ -106,7 +113,7 @@ func (args Arguments) validate() error {
return fmt.Errorf("only one of Version(%q) or Track(%q) can be set", args.Version, args.Track)
}
switch args.Track {
case StableTrack, UnstableTrack, CurrentTrack:
case StableTrack, UnstableTrack, "":
// All valid values.
default:
return fmt.Errorf("unsupported track %q", args.Track)
@@ -119,11 +126,17 @@ type Updater struct {
// Update is a platform-specific method that updates the installation. May be
// nil (not all platforms support updates from within Tailscale).
Update func() error
// currentVersion is the short form of the current client version as
// returned by version.Short(), typically "x.y.z". Used for tests to
// override the actual current version.
currentVersion string
}
func NewUpdater(args Arguments) (*Updater, error) {
up := Updater{
Arguments: args,
Arguments: args,
currentVersion: version.Short(),
}
if up.Stdout == nil {
up.Stdout = os.Stdout
@@ -139,18 +152,15 @@ func NewUpdater(args Arguments) (*Updater, error) {
if args.ForAutoUpdate && !canAutoUpdate {
return nil, errors.ErrUnsupported
}
if up.Track == CurrentTrack {
switch {
case up.Version != "":
if up.Track == "" {
if up.Version != "" {
var err error
up.Track, err = versionToTrack(args.Version)
if err != nil {
return nil, err
}
case version.IsUnstableBuild():
up.Track = UnstableTrack
default:
up.Track = StableTrack
} else {
up.Track = CurrentTrack
}
}
if up.Arguments.PkgsAddr == "" {
@@ -238,6 +248,11 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
// CanAutoUpdate reports whether auto-updating via the clientupdate package
// is supported for the current os/distro.
func CanAutoUpdate() bool {
if version.IsMacSysExt() {
// Macsys uses Sparkle for auto-updates, which doesn't have an update
// function in this package.
return true
}
_, canAutoUpdate := (&Updater{}).getUpdateFunction()
return canAutoUpdate
}
@@ -259,13 +274,16 @@ func Update(args Arguments) error {
}
func (up *Updater) confirm(ver string) bool {
switch cmpver.Compare(version.Short(), ver) {
case 0:
up.Logf("already running %v version %v; no update needed", up.Track, ver)
return false
case 1:
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, version.Short(), ver)
return false
// Only check version when we're not switching tracks.
if up.Track == "" || up.Track == CurrentTrack {
switch c := cmpver.Compare(up.currentVersion, ver); {
case c == 0:
up.Logf("already running %v version %v; no update needed", up.Track, ver)
return false
case c > 0:
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, up.currentVersion, ver)
return false
}
}
if up.Confirm != nil {
return up.Confirm(ver)
@@ -681,7 +699,7 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
return "", fmt.Errorf("malformed info line: %q", line)
}
ver := parts[1]
if cmpver.Compare(ver, maxVer) == 1 {
if cmpver.Compare(ver, maxVer) > 0 {
maxVer = ver
}
}
@@ -880,7 +898,7 @@ func (up *Updater) installMSI(msi string) error {
break
}
up.Logf("Install attempt failed: %v", err)
uninstallVersion := version.Short()
uninstallVersion := up.currentVersion
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
uninstallVersion = v
}
@@ -1331,12 +1349,8 @@ func requestedTailscaleVersion(ver, track string) (string, error) {
// LatestTailscaleVersion returns the latest released version for the given
// track from pkgs.tailscale.com.
func LatestTailscaleVersion(track string) (string, error) {
if track == CurrentTrack {
if version.IsUnstableBuild() {
track = UnstableTrack
} else {
track = StableTrack
}
if track == "" {
track = CurrentTrack
}
latest, err := latestPackages(track)

View File

@@ -846,3 +846,107 @@ func TestParseUnraidPluginVersion(t *testing.T) {
})
}
}
func TestConfirm(t *testing.T) {
curTrack := CurrentTrack
defer func() { CurrentTrack = curTrack }()
tests := []struct {
desc string
fromTrack string
toTrack string
fromVer string
toVer string
confirm func(string) bool
want bool
}{
{
desc: "on latest stable",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.0",
toVer: "1.66.0",
want: false,
},
{
desc: "stable upgrade",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.0",
toVer: "1.68.0",
want: true,
},
{
desc: "unstable upgrade",
fromTrack: UnstableTrack,
toTrack: UnstableTrack,
fromVer: "1.67.1",
toVer: "1.67.2",
want: true,
},
{
desc: "from stable to unstable",
fromTrack: StableTrack,
toTrack: UnstableTrack,
fromVer: "1.66.0",
toVer: "1.67.1",
want: true,
},
{
desc: "from unstable to stable",
fromTrack: UnstableTrack,
toTrack: StableTrack,
fromVer: "1.67.1",
toVer: "1.66.0",
want: true,
},
{
desc: "confirm callback rejects",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.0",
toVer: "1.66.1",
confirm: func(string) bool {
return false
},
want: false,
},
{
desc: "confirm callback allows",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.0",
toVer: "1.66.1",
confirm: func(string) bool {
return true
},
want: true,
},
{
desc: "downgrade",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.1",
toVer: "1.66.0",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
CurrentTrack = tt.fromTrack
up := Updater{
currentVersion: tt.fromVer,
Arguments: Arguments{
Track: tt.toTrack,
Confirm: tt.confirm,
Logf: t.Logf,
},
}
if got := up.confirm(tt.toVer); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -78,7 +78,11 @@ func main() {
w(" return false")
w("}")
}
cloneOutput := pkg.Name + "_clone.go"
cloneOutput := pkg.Name + "_clone"
if *flagBuildTags == "test" {
cloneOutput += "_test"
}
cloneOutput += ".go"
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
log.Fatal(err)
}
@@ -91,16 +95,19 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
}
name := typ.Obj().Name()
typeParams := typ.Origin().TypeParams()
_, typeParamNames := codegen.FormatTypeParams(typeParams, it)
nameWithParams := name + typeParamNames
fmt.Fprintf(buf, "// Clone makes a deep copy of %s.\n", name)
fmt.Fprintf(buf, "// The result aliases no memory with the original.\n")
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", name, name)
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", nameWithParams, nameWithParams)
writef := func(format string, args ...any) {
fmt.Fprintf(buf, "\t"+format+"\n", args...)
}
writef("if src == nil {")
writef("\treturn nil")
writef("}")
writef("dst := new(%s)", name)
writef("dst := new(%s)", nameWithParams)
writef("*dst = *src")
for i := range t.NumFields() {
fname := t.Field(i).Name()
@@ -126,16 +133,23 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
writef("for i := range dst.%s {", fname)
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
it.Import("tailscale.com/types/ptr")
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname)
writef("}")
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
if codegen.ContainsPointers(ptr.Elem()) {
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
it.Import("tailscale.com/types/ptr")
writef("\tdst.%s[i] = ptr.To((*src.%s[i]).Clone())", fname, fname)
} else {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
}
} else {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
it.Import("tailscale.com/types/ptr")
writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname)
}
writef("}")
} else if ft.Elem().String() == "encoding/json.RawMessage" {
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
} else if _, isIface := ft.Elem().Underlying().(*types.Interface); isIface {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
} else {
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
}
@@ -145,14 +159,19 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("dst.%s = append(src.%s[:0:0], src.%s...)", fname, fname, fname)
}
case *types.Pointer:
if named, _ := ft.Elem().(*types.Named); named != nil && codegen.ContainsPointers(ft.Elem()) {
base := ft.Elem()
hasPtrs := codegen.ContainsPointers(base)
if named, _ := base.(*types.Named); named != nil && hasPtrs {
writef("dst.%s = src.%s.Clone()", fname, fname)
continue
}
it.Import("tailscale.com/types/ptr")
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
if codegen.ContainsPointers(ft.Elem()) {
if _, isIface := base.Underlying().(*types.Interface); isIface && hasPtrs {
writef("\tdst.%s = ptr.To((*src.%s).Clone())", fname, fname)
} else if !hasPtrs {
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
} else {
writef("\t" + `panic("TODO pointers in pointers")`)
}
writef("}")
@@ -172,18 +191,50 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
writef("\tfor k, v := range src.%s {", fname)
switch elem.(type) {
switch elem := elem.Underlying().(type) {
case *types.Pointer:
writef("\t\tdst.%s[k] = v.Clone()", fname)
writef("\t\tif v == nil { dst.%s[k] = nil } else {", fname)
if base := elem.Elem().Underlying(); codegen.ContainsPointers(base) {
if _, isIface := base.(*types.Interface); isIface {
it.Import("tailscale.com/types/ptr")
writef("\t\t\tdst.%s[k] = ptr.To((*v).Clone())", fname)
} else {
writef("\t\t\tdst.%s[k] = v.Clone()", fname)
}
} else {
it.Import("tailscale.com/types/ptr")
writef("\t\t\tdst.%s[k] = ptr.To(*v)", fname)
}
writef("}")
case *types.Interface:
if cloneResultType := methodResultType(elem, "Clone"); cloneResultType != nil {
if _, isPtr := cloneResultType.(*types.Pointer); isPtr {
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
} else {
writef("\t\tdst.%s[k] = v.Clone()", fname)
}
} else {
writef(`panic("%s (%v) does not have a Clone method")`, fname, elem)
}
default:
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
}
writef("\t}")
writef("}")
} else {
it.Import("maps")
writef("\tdst.%s = maps.Clone(src.%s)", fname, fname)
}
case *types.Interface:
// If ft is an interface with a "Clone() ft" method, it can be used to clone the field.
// This includes scenarios where ft is a constrained type parameter.
if cloneResultType := methodResultType(ft, "Clone"); cloneResultType.Underlying() == ft {
writef("dst.%s = src.%s.Clone()", fname, fname)
continue
}
writef(`panic("%s (%v) does not have a compatible Clone method")`, fname, ft)
default:
writef(`panic("TODO: %s (%T)")`, fname, ft)
}
@@ -191,7 +242,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("return dst")
fmt.Fprintf(buf, "}\n\n")
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
}
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
@@ -203,3 +254,15 @@ func hasBasicUnderlying(typ types.Type) bool {
return false
}
}
func methodResultType(typ types.Type, method string) types.Type {
viewMethod := codegen.LookupMethod(typ, method)
if viewMethod == nil {
return nil
}
sig, ok := viewMethod.Type().(*types.Signature)
if !ok || sig.Results().Len() != 1 {
return nil
}
return sig.Results().At(0).Type()
}

View File

@@ -3,6 +3,7 @@
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer
// Package clonerex is an example package for the cloner tool.
package clonerex
type SliceContainer struct {

View File

@@ -19,22 +19,20 @@ import (
"tailscale.com/tailcfg"
)
// storeDeviceInfo writes deviceID into the "device_id" data field of the kube
// secret secretName.
func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string, addresses []netip.Prefix) error {
// First check if the secret exists at all. Even if running on
// kubernetes, we do not necessarily store state in a k8s secret.
if _, err := kc.GetSecret(ctx, secretName); err != nil {
if s, ok := err.(*kube.Status); ok {
if s.Code >= 400 && s.Code <= 499 {
// Assume the secret doesn't exist, or we don't have
// permission to access it.
return nil
}
}
return err
// 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{
Data: map[string][]byte{
"device_id": []byte(deviceID),
},
}
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
}
// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields
// 'device_ips', 'device_fqdn' of the named Kubernetes Secret.
func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, addresses []netip.Prefix) error {
var ips []string
for _, addr := range addresses {
ips = append(ips, addr.Addr().String())
@@ -44,14 +42,13 @@ func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.St
return err
}
m := &kube.Secret{
s := &kube.Secret{
Data: map[string][]byte{
"device_id": []byte(deviceID),
"device_fqdn": []byte(fqdn),
"device_ips": deviceIPs,
},
}
return kc.StrategicMergePatchSecret(ctx, secretName, m, "tailscale-container")
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
}
// deleteAuthKey deletes the 'authkey' field of the given kube

View File

@@ -52,6 +52,12 @@
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes,
// and will be re-applied when it changes.
// - TS_HEALTHCHECK_ADDR_PORT: if specified, an HTTP health endpoint will be
// served at /healthz at the provided address, which should be in form [<address>]:<port>.
// If not set, no health check will be run. If set to :<port>, addr will default to 0.0.0.0
// The health endpoint will return 200 OK if this node has at least one tailnet IP address,
// otherwise returns 503.
// NB: the health criteria might change in the future.
// - TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR: if specified, a path to a
// directory that containers tailscaled config in file. The config file needs to be
// named cap-<current-tailscaled-cap>.hujson. If this is set, TS_HOSTNAME,
@@ -95,6 +101,7 @@ import (
"log"
"math"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
@@ -158,6 +165,7 @@ func main() {
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
PodIP: defaultEnv("POD_IP", ""),
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
}
if err := cfg.validate(); err != nil {
@@ -321,7 +329,7 @@ authLoop:
}
}
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && isTwoStepConfigAuthOnce(cfg) {
if hasKubeStateStore(cfg) && isTwoStepConfigAuthOnce(cfg) {
// We were told to only auth once, so any secret-bound
// authkey is no longer needed. We don't strictly need to
// wipe it, but it's good hygiene.
@@ -337,11 +345,10 @@ authLoop:
}
var (
wantProxy = cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
wantDeviceInfo = cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch
startupTasksDone = false
currentIPs deephash.Sum // tailscale IPs assigned to device
currentDeviceInfo deephash.Sum // device ID and fqdn
startupTasksDone = false
currentIPs deephash.Sum // tailscale IPs assigned to device
currentDeviceID deephash.Sum // device ID
currentDeviceEndpoints deephash.Sum // device FQDN and IPs
currentEgressIPs deephash.Sum
@@ -350,12 +357,15 @@ authLoop:
certDomain = new(atomic.Pointer[string])
certDomainChanged = make(chan bool, 1)
h = &healthz{} // http server for the healthz endpoint
healthzRunner = sync.OnceFunc(func() { runHealthz(cfg.HealthCheckAddrPort, h) })
)
if cfg.ServeConfigPath != "" {
go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client)
}
var nfr linuxfw.NetfilterRunner
if wantProxy {
if isL3Proxy(cfg) {
nfr, err = newNetfilterRunner(log.Printf)
if err != nil {
log.Fatalf("error creating new netfilter runner: %v", err)
@@ -440,6 +450,20 @@ runLoop:
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
// Store device ID in a Kubernetes Secret before
// setting up any routing rules. This ensures
// that, for containerboot instances that are
// Kubernetes operator proxies, the operator is
// able to retrieve the device ID from the
// Kubernetes Secret to clean up tailnet nodes
// for proxies whose route setup continuously
// fails.
deviceID := n.NetMap.SelfNode.StableID()
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) {
if err := storeDeviceID(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID()); err != nil {
log.Fatalf("storing device ID in Kubernetes Secret: %v", err)
}
}
if cfg.TailnetTargetFQDN != "" {
var (
egressAddrs []netip.Prefix
@@ -463,18 +487,20 @@ runLoop:
newCurentEgressIPs = deephash.Hash(&egressAddrs)
egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
if egressIPsHaveChanged && len(egressAddrs) != 0 {
var rulesInstalled bool
for _, egressAddr := range egressAddrs {
ea := egressAddr.Addr()
// TODO (irbekrm): make it work for IPv6 too.
if ea.Is6() {
log.Println("Not installing egress forwarding rules for IPv6 as this is currently not supported")
continue
}
log.Printf("Installing forwarding rules for destination %v", ea.String())
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) {
rulesInstalled = true
log.Printf("Installing forwarding rules for destination %v", ea.String())
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
}
}
}
if !rulesInstalled {
log.Fatalf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT())
}
}
currentEgressIPs = newCurentEgressIPs
}
@@ -533,15 +559,43 @@ runLoop:
}
currentIPs = newCurrentIPs
deviceInfo := []any{n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name()}
if cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" && deephash.Update(&currentDeviceInfo, &deviceInfo) {
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
log.Fatalf("storing device ID in kube secret: %v", err)
// Only store device FQDN and IP addresses to
// Kubernetes Secret when any required proxy
// route setup has succeeded. IPs and FQDN are
// read from the Secret by the Tailscale
// Kubernetes operator and, for some proxy
// types, such as Tailscale Ingress, advertized
// on the Ingress status. Writing them to the
// Secret only after the proxy routing has been
// set up ensures that the operator does not
// advertize endpoints of broken proxies.
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()}
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceEndpoints, &deviceEndpoints) {
if err := storeDeviceEndpoints(ctx, cfg.KubeSecret, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
log.Fatalf("storing device IPs and FQDN in Kubernetes Secret: %v", err)
}
}
if cfg.HealthCheckAddrPort != "" {
h.Lock()
h.hasAddrs = len(addrs) != 0
h.Unlock()
healthzRunner()
}
}
if !startupTasksDone {
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
// For containerboot instances that act as TCP
// proxies (proxying traffic to an endpoint
// passed via one of the env vars that
// containerbot reads) and store state in a
// Kubernetes Secret, we consider startup tasks
// done at the point when device info has been
// successfully stored to state Secret.
// For all other containerboot instances, if we
// just get to this point the startup tasks can
// be considered done.
if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) {
// This log message is used in tests to detect when all
// post-auth configuration is done.
log.Println("Startup complete, waiting for shutdown signal")
@@ -907,7 +961,7 @@ func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
return nil
}
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
@@ -1116,7 +1170,8 @@ type settings struct {
// 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
PodIP string
HealthCheckAddrPort string
}
func (s *settings) validate() error {
@@ -1165,6 +1220,11 @@ func (s *settings) validate() error {
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
}
@@ -1287,6 +1347,19 @@ 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
@@ -1325,3 +1398,41 @@ 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

@@ -52,7 +52,7 @@ func TestContainerBoot(t *testing.T) {
}
defer kube.Close()
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: func(s string) *string { return &s }("foo"), Version: "alpha0"}
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: ptr.To("foo"), Version: "alpha0"}
tailscaledConfBytes, err := json.Marshal(tailscaledConf)
if err != nil {
t.Fatalf("error unmarshaling tailscaled config: %v", err)
@@ -116,6 +116,9 @@ func TestContainerBoot(t *testing.T) {
// WantFiles files that should exist in the container and their
// contents.
WantFiles map[string]string
// WantFatalLog is the fatal log message we expect from containerboot.
// If set for a phase, the test will finish on that phase.
WantFatalLog string
}
runningNotify := &ipn.Notify{
State: ptr.To(ipn.Running),
@@ -349,12 +352,57 @@ func TestContainerBoot(t *testing.T) {
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
WantFiles: map[string]string{
"proc/sys/net/ipv4/ip_forward": "1",
"proc/sys/net/ipv6/conf/all/forwarding": "0",
},
},
{
Notify: runningNotify,
},
},
},
{
Name: "egress_proxy_fqdn_ipv6_target_on_ipv4_host",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_TAILNET_TARGET_FQDN": "ipv6-node.test.ts.net", // resolves to IPv6 address
"TS_USERSPACE": "false",
"TS_TEST_FAKE_NETFILTER_6": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
},
WantFiles: map[string]string{
"proc/sys/net/ipv4/ip_forward": "1",
"proc/sys/net/ipv6/conf/all/forwarding": "0",
},
},
{
Notify: &ipn.Notify{
State: ptr.To(ipn.Running),
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
StableID: tailcfg.StableNodeID("ipv6ID"),
Name: "ipv6-node.test.ts.net",
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
}).View(),
},
},
},
WantFatalLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
},
},
},
{
Name: "authkey_once",
Env: map[string]string{
@@ -697,6 +745,25 @@ func TestContainerBoot(t *testing.T) {
var wantCmds []string
for i, p := range test.Phases {
lapi.Notify(p.Notify)
if p.WantFatalLog != "" {
err := tstest.WaitFor(2*time.Second, func() error {
state, err := cmd.Process.Wait()
if err != nil {
return err
}
if state.ExitCode() != 1 {
return fmt.Errorf("process exited with code %d but wanted %d", state.ExitCode(), 1)
}
waitLogLine(t, time.Second, cbOut, p.WantFatalLog)
return nil
})
if err != nil {
t.Fatal(err)
}
// Early test return, we don't expect the successful startup log message.
return
}
wantCmds = append(wantCmds, p.WantCmds...)
waitArgs(t, 2*time.Second, d, argFile, strings.Join(wantCmds, "\n"))
err := tstest.WaitFor(2*time.Second, func() error {

109
cmd/derper/README.md Normal file
View File

@@ -0,0 +1,109 @@
# DERP
This is the code for the [Tailscale DERP server](https://tailscale.com/kb/1232/derp-servers).
In general, you should not need to or want to run this code. The overwhelming
majority of Tailscale users (both individuals and companies) do not.
In the happy path, Tailscale establishes direct connections between peers and
data plane traffic flows directly between them, without using DERP for more than
acting as a low bandwidth side channel to bootstrap the NAT traversal. If you
find yourself wanting DERP for more bandwidth, the real problem is usually the
network configuration of your Tailscale node(s), making sure that Tailscale can
get direction connections via some mechanism.
If you've decided or been advised to run your own `derper`, then read on.
## Caveats
* Node sharing and other cross-Tailnet features don't work when using custom
DERP servers.
* DERP servers only see encrypted WireGuard packets and thus are not useful for
network-level debugging.
* The Tailscale control plane does certain geo-level steering features and
optimizations that are not available when using custom DERP servers.
## Guide to running `cmd/derper`
* You must build and update the `cmd/derper` binary yourself. There are no
packages. Use `go install tailscale.com/cmd/derper@latest` with the latest
version of Go. You should update this binary approximately as regularly as
you update Tailscale nodes. If using `--verify-clients`, the `derper` binary
and `tailscaled` binary on the machine must be built from the same git revision.
(It might work otherwise, but they're developed and only tested together.)
* The DERP protocol does a protocol switch inside TLS from HTTP to a custom
bidirectional binary protocol. It is thus incompatible with many HTTP proxies.
Do not put `derper` behind another HTTP proxy.
* The `tailscaled` client does its own selection of the fastest/nearest DERP
server based on latency measurements. Do not put `derper` behind a global load
balancer.
* DERP servers should ideally have both a static IPv4 and static IPv6 address.
Both of those should be listed in the DERP map so the client doesn't need to
rely on its DNS which might be broken and dependent on DERP to get back up.
* A DERP server should not share an IP address with any other DERP server.
* Avoid having multiple DERP nodes in a region. If you must, they all need to be
meshed with each other and monitored. Having two one-node "regions" in the
same datacenter is usually easier and more reliable than meshing, at the cost
of more required connections from clients in some cases. If your clients
aren't mobile (battery constrained), one node regions are definitely
preferred. If you really need multiple nodes in a region for HA reasons, two
is sufficient.
* Monitor your DERP servers with [`cmd/derpprobe`](../derpprobe/).
* If using `--verify-clients`, a `tailscaled` must be running alongside the
`derper`, and all clients must be visible to the derper tailscaled in the ACL.
* If using `--verify-clients`, a `tailscaled` must also be running alongside
your `derpprobe`, and `derpprobe` needs to use `--derp-map=local`.
* The firewall on the `derper` should permit TCP ports 80 and 443 and UDP port
3478.
* Only LetsEncrypt certs are rotated automatically. Other cert updates require a
restart.
* Don't use a firewall in front of `derper` that suppresses `RST`s upon
receiving traffic to a dead or unknown connection.
* Don't rate-limit UDP STUN packets.
* Don't rate-limit outbound TCP traffic (only inbound).
## Diagnostics
This is not a complete guide on DERP diagnostics.
Running your own DERP services requires exeprtise in multi-layer network and
application diagnostics. As the DERP runs multiple protocols at multiple layers
and is not a regular HTTP(s) server you will need expertise in correlative
analysis to diagnose the most tricky problems. There is no "plain text" or
"open" mode of operation for DERP.
* The debug handler is accessible at URL path `/debug/`. It is only accessible
over localhost or from a Tailscale IP address.
* Go pprof can be accessed via the debug handler at `/debug/pprof/`
* Prometheus compatible metrics can be gathered from the debug handler at
`/debug/varz`.
* `cmd/stunc` in the Tailscale repository provides a basic tool for diagnosing
issues with STUN.
* `cmd/derpprobe` provides a service for monitoring DERP cluster health.
* `tailscale debug derp` and `tailscale netcheck` provide additional client
driven diagnostic information for DERP communications.
* Tailscale logs may provide insight for certain problems, such as if DERPs are
unreachable or peers are regularly not reachable in their DERP home regions.
There are many possible misconfiguration causes for these problems, but
regular log entries are a good first indicator that there is a problem.

View File

@@ -7,9 +7,19 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
github.com/coder/websocket from tailscale.com/cmd/derper+
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
@@ -46,7 +56,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/net/tsaddr+
go4.org/netipx from tailscale.com/net/tsaddr
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+
google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt
google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+
@@ -76,10 +86,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
google.golang.org/protobuf/runtime/protoiface from google.golang.org/protobuf/internal/impl+
google.golang.org/protobuf/runtime/protoimpl from github.com/prometheus/client_model/go+
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
nhooyr.io/websocket from tailscale.com/cmd/derper+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
tailscale.com from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/cmd/derper+
tailscale.com/client/tailscale from tailscale.com/derp
@@ -95,14 +101,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/metrics from tailscale.com/cmd/derper+
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/flowtrack from tailscale.com/net/packet+
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netknob from tailscale.com/net/netns
💣 tailscale.com/net/netmon from tailscale.com/derp/derphttp+
tailscale.com/net/netns from tailscale.com/derp/derphttp
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp
tailscale.com/net/netutil from tailscale.com/client/tailscale
tailscale.com/net/packet from tailscale.com/wgengine/filter
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
tailscale.com/net/stun from tailscale.com/net/stunserver
tailscale.com/net/stunserver from tailscale.com/cmd/derper
@@ -116,16 +120,16 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/netmon
W tailscale.com/tsconst from tailscale.com/net/netmon+
tailscale.com/tstime from tailscale.com/derp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/derp+
tailscale.com/tstime/rate from tailscale.com/derp
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/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/ipproto from tailscale.com/tailcfg+
tailscale.com/types/key from tailscale.com/client/tailscale+
tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/cmd/derper+
@@ -142,9 +146,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/ctxkey from tailscale.com/tsweb+
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/fastuuid from tailscale.com/tsweb
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
@@ -155,12 +161,14 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
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/vizerror from tailscale.com/tailcfg+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/envknob+
tailscale.com/wgengine/filter from tailscale.com/types/netmap
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert
golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper
golang.org/x/crypto/argon2 from tailscale.com/tka
@@ -176,6 +184,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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+
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+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http

View File

@@ -2,6 +2,12 @@
// SPDX-License-Identifier: BSD-3-Clause
// The derper binary is a simple DERP server.
//
// For more information, see:
//
// - About: https://tailscale.com/kb/1232/derp-servers
// - Protocol & Go docs: https://pkg.go.dev/tailscale.com/derp
// - Running a DERP server: https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp
package main // import "tailscale.com/cmd/derper"
import (
@@ -22,6 +28,9 @@ import (
"os/signal"
"path/filepath"
"regexp"
"runtime"
runtimemetrics "runtime/metrics"
"strconv"
"strings"
"syscall"
"time"
@@ -206,11 +215,16 @@ func main() {
io.WriteString(w, `<html><body>
<h1>DERP</h1>
<p>
This is a
<a href="https://tailscale.com/">Tailscale</a>
<a href="https://pkg.go.dev/tailscale.com/derp">DERP</a>
server.
This is a <a href="https://tailscale.com/">Tailscale</a> DERP server.
</p>
<p>
Documentation:
</p>
<ul>
<li><a href="https://tailscale.com/kb/1232/derp-servers">About DERP</a></li>
<li><a href="https://pkg.go.dev/tailscale.com/derp">Protocol & Go docs</a></li>
<li><a href="https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp">How to run a DERP server</a></li>
</ul>
`)
if !*runDERP {
io.WriteString(w, `<p>Status: <b>disabled</b></p>`)
@@ -223,7 +237,7 @@ func main() {
tsweb.AddBrowserHeaders(w)
io.WriteString(w, "User-agent: *\nDisallow: /\n")
}))
mux.Handle("/generate_204", http.HandlerFunc(serveNoContent))
mux.Handle("/generate_204", http.HandlerFunc(derphttp.ServeNoContent))
debug := tsweb.Debugger(mux)
debug.KV("TLS hostname", *hostname)
debug.KV("Mesh key", s.HasMeshKey())
@@ -236,6 +250,20 @@ func main() {
}
}))
debug.Handle("traffic", "Traffic check", http.HandlerFunc(s.ServeDebugTraffic))
debug.Handle("set-mutex-profile-fraction", "SetMutexProfileFraction", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := r.FormValue("rate")
if s == "" || r.Header.Get("Sec-Debug") != "derp" {
http.Error(w, "To set, use: curl -HSec-Debug:derp 'http://derp/debug/set-mutex-profile-fraction?rate=100'", http.StatusBadRequest)
return
}
v, err := strconv.Atoi(s)
if err != nil {
http.Error(w, "bad rate value", http.StatusBadRequest)
return
}
old := runtime.SetMutexProfileFraction(v)
fmt.Fprintf(w, "mutex changed from %v to %v\n", old, v)
}))
// Longer lived DERP connections send an application layer keepalive. Note
// if the keepalive is hit, the user timeout will take precedence over the
@@ -309,7 +337,7 @@ func main() {
if *httpPort > -1 {
go func() {
port80mux := http.NewServeMux()
port80mux.HandleFunc("/generate_204", serveNoContent)
port80mux.HandleFunc("/generate_204", derphttp.ServeNoContent)
port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux}))
port80srv := &http.Server{
Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)),
@@ -350,31 +378,6 @@ func main() {
}
}
const (
noContentChallengeHeader = "X-Tailscale-Challenge"
noContentResponseHeader = "X-Tailscale-Response"
)
// For captive portal detection
func serveNoContent(w http.ResponseWriter, r *http.Request) {
if challenge := r.Header.Get(noContentChallengeHeader); challenge != "" {
badChar := strings.IndexFunc(challenge, func(r rune) bool {
return !isChallengeChar(r)
}) != -1
if len(challenge) <= 64 && !badChar {
w.Header().Set(noContentResponseHeader, "response "+challenge)
}
}
w.WriteHeader(http.StatusNoContent)
}
func isChallengeChar(c rune) bool {
// Semi-randomly chosen as a limited set of valid characters
return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
('0' <= c && c <= '9') ||
c == '.' || c == '-' || c == '_'
}
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
func prodAutocertHostPolicy(_ context.Context, host string) error {
@@ -452,3 +455,16 @@ func (l *rateLimitedListener) Accept() (net.Conn, error) {
l.numAccepts.Add(1)
return cn, nil
}
func init() {
expvar.Publish("go_sync_mutex_wait_seconds", expvar.Func(func() any {
const name = "/sync/mutex/wait/total:seconds" // Go 1.20+
var s [1]runtimemetrics.Sample
s[0].Name = name
runtimemetrics.Read(s[:])
if v := s[0].Value; v.Kind() == runtimemetrics.KindFloat64 {
return v.Float64()
}
return 0
}))
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"tailscale.com/derp/derphttp"
"tailscale.com/tstest/deptest"
)
@@ -76,20 +77,20 @@ func TestNoContent(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "https://localhost/generate_204", nil)
if tt.input != "" {
req.Header.Set(noContentChallengeHeader, tt.input)
req.Header.Set(derphttp.NoContentChallengeHeader, tt.input)
}
w := httptest.NewRecorder()
serveNoContent(w, req)
derphttp.ServeNoContent(w, req)
resp := w.Result()
if tt.want == "" {
if h, found := resp.Header[noContentResponseHeader]; found {
if h, found := resp.Header[derphttp.NoContentResponseHeader]; found {
t.Errorf("got %+v; expected no response header", h)
}
return
}
if got := resp.Header.Get(noContentResponseHeader); got != tt.want {
if got := resp.Header.Get(derphttp.NoContentResponseHeader); got != tt.want {
t.Errorf("got %q; want %q", got, tt.want)
}
})
@@ -104,6 +105,8 @@ func TestDeps(t *testing.T) {
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
"tailscale.com/net/packet": "not needed in derper",
"github.com/gaissmai/bart": "not needed in derper",
},
}.Check(t)
}

View File

@@ -9,14 +9,12 @@ import (
"fmt"
"log"
"net"
"net/netip"
"strings"
"time"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/net/netmon"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -71,8 +69,8 @@ func startMeshWithHost(s *derp.Server, host string) error {
return d.DialContext(ctx, network, addr)
})
add := func(k key.NodePublic, _ netip.AddrPort) { s.AddPacketForwarder(k, c) }
remove := func(k key.NodePublic) { s.RemovePacketForwarder(k, c) }
add := func(m derp.PeerPresentMessage) { s.AddPacketForwarder(m.Key, c) }
remove := func(m derp.PeerGoneMessage) { s.RemovePacketForwarder(m.Peer, c) }
go c.RunWatchConnectionLoop(context.Background(), s.PublicKey(), logf, add, remove)
return nil
}

View File

@@ -10,7 +10,7 @@ import (
"net/http"
"strings"
"nhooyr.io/websocket"
"github.com/coder/websocket"
"tailscale.com/derp"
"tailscale.com/net/wsconn"
)

View File

@@ -7,8 +7,6 @@ package main
import (
"flag"
"fmt"
"html"
"io"
"log"
"net/http"
"sort"
@@ -70,8 +68,13 @@ func main() {
}
mux := http.NewServeMux()
tsweb.Debugger(mux)
mux.HandleFunc("/", http.HandlerFunc(serveFunc(p)))
d := tsweb.Debugger(mux)
d.Handle("probe-run", "Run a probe", tsweb.StdHandler(tsweb.ReturnHandlerFunc(p.RunHandler), tsweb.HandlerOptions{Logf: log.Printf}))
mux.Handle("/", tsweb.StdHandler(p.StatusHandler(
prober.WithTitle("DERP Prober"),
prober.WithPageLink("Prober metrics", "/debug/varz"),
prober.WithProbeLink("Run Probe", "/debug/probe-run?name={{.Name}}"),
), tsweb.HandlerOptions{Logf: log.Printf}))
log.Printf("Listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, mux))
}
@@ -105,26 +108,3 @@ func getOverallStatus(p *prober.Prober) (o overallStatus) {
sort.Strings(o.good)
return
}
func serveFunc(p *prober.Prober) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
st := getOverallStatus(p)
summary := "All good"
if (float64(len(st.bad)) / float64(len(st.bad)+len(st.good))) > 0.25 {
// Returning a 500 allows monitoring this server externally and configuring
// an alert on HTTP response code.
w.WriteHeader(500)
summary = fmt.Sprintf("%d problems", len(st.bad))
}
io.WriteString(w, "<html><head><style>.bad { font-weight: bold; color: #700; }</style></head>\n")
fmt.Fprintf(w, "<body><h1>derp probe</h1>\n%s:<ul>", summary)
for _, s := range st.bad {
fmt.Fprintf(w, "<li class=bad>%s</li>\n", html.EscapeString(s))
}
for _, s := range st.good {
fmt.Fprintf(w, "<li>%s</li>\n", html.EscapeString(s))
}
io.WriteString(w, "</ul></body></html>\n")
}
}

View File

@@ -28,19 +28,20 @@ import (
)
var (
rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError)
policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file")
cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash")
timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)")
apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact")
rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError)
policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file")
cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash")
timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)")
apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact")
failOnManualEdits = rootFlagSet.Bool("fail-on-manual-edits", false, "fail if manual edits to the ACLs in the admin panel are detected; when set to false (the default) only a warning is printed")
)
func modifiedExternallyError() {
func modifiedExternallyError() error {
if *githubSyntax {
fmt.Printf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.\n", *policyFname)
return fmt.Errorf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.", *policyFname)
} else {
fmt.Printf("The policy file was modified externally in the admin console.\n")
return fmt.Errorf("The policy file was modified externally in the admin console.")
}
}
@@ -65,16 +66,22 @@ func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(conte
log.Printf("local: %s", localEtag)
log.Printf("cache: %s", cache.PrevETag)
if cache.PrevETag != controlEtag {
modifiedExternallyError()
}
if controlEtag == localEtag {
cache.PrevETag = localEtag
log.Println("no update needed, doing nothing")
return nil
}
if cache.PrevETag != controlEtag {
if err := modifiedExternallyError(); err != nil {
if *failOnManualEdits {
return err
} else {
fmt.Println(err)
}
}
}
if err := applyNewACL(ctx, client, tailnet, apiKey, *policyFname, controlEtag); err != nil {
return err
}
@@ -106,15 +113,21 @@ func test(cache *Cache, client *http.Client, tailnet, apiKey string) func(contex
log.Printf("local: %s", localEtag)
log.Printf("cache: %s", cache.PrevETag)
if cache.PrevETag != controlEtag {
modifiedExternallyError()
}
if controlEtag == localEtag {
log.Println("no updates found, doing nothing")
return nil
}
if cache.PrevETag != controlEtag {
if err := modifiedExternallyError(); err != nil {
if *failOnManualEdits {
return err
} else {
fmt.Println(err)
}
}
}
if err := testNewACLs(ctx, client, tailnet, apiKey, *policyFname); err != nil {
return err
}

View File

@@ -33,11 +33,8 @@ import (
const (
reasonConnectorCreationFailed = "ConnectorCreationFailed"
reasonConnectorCreated = "ConnectorCreated"
reasonConnectorCleanupFailed = "ConnectorCleanupFailed"
reasonConnectorCleanupInProgress = "ConnectorCleanupInProgress"
reasonConnectorInvalid = "ConnectorInvalid"
reasonConnectorCreated = "ConnectorCreated"
reasonConnectorInvalid = "ConnectorInvalid"
messageConnectorCreationFailed = "Failed creating Connector: %v"
messageConnectorInvalid = "Connector is invalid: %v"
@@ -108,7 +105,7 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
}
oldCnStatus := cn.Status.DeepCopy()
setStatus := func(cn *tsapi.Connector, _ tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
setStatus := func(cn *tsapi.Connector, _ tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, status, reason, message, cn.Generation, a.clock, logger)
if !apiequality.Semantic.DeepEqual(oldCnStatus, cn.Status) {
// An error encountered here should get returned by the Reconcile function.

View File

@@ -272,9 +272,9 @@ func TestConnectorWithProxyClass(t *testing.T) {
// its resources.
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
pc.Status = tsapi.ProxyClassStatus{
Conditions: []tsapi.ConnectorCondition{{
Conditions: []metav1.Condition{{
Status: metav1.ConditionTrue,
Type: tsapi.ProxyClassready,
Type: string(tsapi.ProxyClassready),
ObservedGeneration: pc.Generation,
}}}
})

File diff suppressed because it is too large Load Diff

View File

@@ -77,6 +77,9 @@ spec:
value: "{{ .Values.apiServerProxyConfig.mode }}"
- name: PROXY_FIREWALL_MODE
value: {{ .Values.proxyConfig.firewallMode }}
{{- with .Values.operatorConfig.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: oauth
mountPath: /oauth

View File

@@ -48,6 +48,13 @@ operatorConfig:
securityContext: {}
extraEnv: []
# - name: EXTRA_VAR1
# value: "value1"
# - name: EXTRA_VAR2
# value: "value2"
# proxyConfig contains configuraton that will be applied to any ingress/egress
# proxies created by the operator.
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress

View File

@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.13.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: connectors.tailscale.com
spec:
group: tailscale.com
@@ -31,48 +31,95 @@ spec:
name: v1alpha1
schema:
openAPIV3Schema:
description: 'Connector defines a Tailscale node that will be deployed in the cluster. The node can be configured to act as a Tailscale subnet router and/or a Tailscale exit node. Connector is a cluster-scoped resource. More info: https://tailscale.com/kb/1236/kubernetes-operator#deploying-exit-nodes-and-subnet-routers-on-kubernetes-using-connector-custom-resource'
description: |-
Connector defines a Tailscale node that will be deployed in the cluster. The
node can be configured to act as a Tailscale subnet router and/or a Tailscale
exit node.
Connector is a cluster-scoped resource.
More info:
https://tailscale.com/kb/1236/kubernetes-operator#deploying-exit-nodes-and-subnet-routers-on-kubernetes-using-connector-custom-resource
type: object
required:
- spec
properties:
apiVersion:
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: 'ConnectorSpec describes the desired Tailscale component. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
description: |-
ConnectorSpec describes the desired Tailscale component.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
type: object
properties:
exitNode:
description: ExitNode defines whether the Connector node should act as a Tailscale exit node. Defaults to false. https://tailscale.com/kb/1103/exit-nodes
description: |-
ExitNode defines whether the Connector node should act as a
Tailscale exit node. Defaults to false.
https://tailscale.com/kb/1103/exit-nodes
type: boolean
hostname:
description: Hostname is the tailnet hostname that should be assigned to the Connector node. If unset, hostname defaults to <connector name>-connector. Hostname can contain lower case letters, numbers and dashes, it must not start or end with a dash and must be between 2 and 63 characters long.
description: |-
Hostname is the tailnet hostname that should be assigned to the
Connector node. If unset, hostname defaults to <connector
name>-connector. Hostname can contain lower case letters, numbers and
dashes, it must not start or end with a dash and must be between 2
and 63 characters long.
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$
proxyClass:
description: ProxyClass is the name of the ProxyClass custom resource that contains configuration options that should be applied to the resources created for this Connector. If unset, the operator will create resources with the default configuration.
description: |-
ProxyClass is the name of the ProxyClass custom resource that
contains configuration options that should be applied to the
resources created for this Connector. If unset, the operator will
create resources with the default configuration.
type: string
subnetRouter:
description: SubnetRouter defines subnet routes that the Connector node should expose to tailnet. If unset, none are exposed. https://tailscale.com/kb/1019/subnets/
description: |-
SubnetRouter defines subnet routes that the Connector node should
expose to tailnet. If unset, none are exposed.
https://tailscale.com/kb/1019/subnets/
type: object
required:
- advertiseRoutes
properties:
advertiseRoutes:
description: AdvertiseRoutes refer to CIDRs that the subnet router should make available. Route values must be strings that represent a valid IPv4 or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. https://tailscale.com/kb/1201/4via6-subnets/
description: |-
AdvertiseRoutes refer to CIDRs that the subnet router should make
available. Route values must be strings that represent a valid IPv4
or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes.
https://tailscale.com/kb/1201/4via6-subnets/
type: array
minItems: 1
items:
type: string
format: cidr
tags:
description: Tags that the Tailscale node will be tagged with. Defaults to [tag:k8s]. To autoapprove the subnet routes or exit node defined by a Connector, you can configure Tailscale ACLs to give these tags the necessary permissions. See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes. If you specify custom tags here, you must also make the operator an owner of these tags. See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. Tags cannot be changed once a Connector node has been created. Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
description: |-
Tags that the Tailscale node will be tagged with.
Defaults to [tag:k8s].
To autoapprove the subnet routes or exit node defined by a Connector,
you can configure Tailscale ACLs to give these tags the necessary
permissions.
See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes.
If you specify custom tags here, you must also make the operator an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a Connector node has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
type: array
items:
type: string
@@ -81,53 +128,90 @@ spec:
- rule: has(self.subnetRouter) || self.exitNode == true
message: A Connector needs to be either an exit node or a subnet router, or both.
status:
description: ConnectorStatus describes the status of the Connector. This is set and managed by the Tailscale operator.
description: |-
ConnectorStatus describes the status of the Connector. This is set
and managed by the Tailscale operator.
type: object
properties:
conditions:
description: List of status conditions to indicate the status of the Connector. Known condition types are `ConnectorReady`.
description: |-
List of status conditions to indicate the status of the Connector.
Known condition types are `ConnectorReady`.
type: array
items:
description: ConnectorCondition contains condition information for a Connector.
description: Condition contains details for one aspect of the current state of this API Resource.
type: object
required:
- lastTransitionTime
- message
- reason
- status
- type
properties:
lastTransitionTime:
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
type: string
format: date-time
message:
description: Message is a human readable description of the details of the last transition, complementing reason.
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
type: string
maxLength: 32768
observedGeneration:
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
type: integer
format: int64
minimum: 0
reason:
description: Reason is a brief machine readable explanation for the condition's last transition.
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
type: string
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
status:
description: Status of the condition, one of ('True', 'False', 'Unknown').
description: status of the condition, one of True, False, Unknown.
type: string
enum:
- "True"
- "False"
- Unknown
type:
description: Type of the condition, known values are (`SubnetRouterReady`).
description: type of condition in CamelCase or in foo.example.com/CamelCase.
type: string
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
hostname:
description: Hostname is the fully qualified domain name of the Connector node. If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the node.
description: |-
Hostname is the fully qualified domain name of the Connector node.
If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the
node.
type: string
isExitNode:
description: IsExitNode is set to true if the Connector acts as an exit node.
type: boolean
subnetRoutes:
description: SubnetRoutes are the routes currently exposed to tailnet via this Connector instance.
description: |-
SubnetRoutes are the routes currently exposed to tailnet via this
Connector instance.
type: string
tailnetIPs:
description: TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) assigned to the Connector node.
description: |-
TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
assigned to the Connector node.
type: array
items:
type: string

View File

@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.13.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: dnsconfigs.tailscale.com
spec:
group: tailscale.com
@@ -23,27 +23,69 @@ spec:
name: v1alpha1
schema:
openAPIV3Schema:
description: 'DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS names resolvable by cluster workloads. Use this if: A) you need to refer to tailnet services, exposed to cluster via Tailscale Kubernetes operator egress proxies by the MagicDNS names of those tailnet services (usually because the services run over HTTPS) B) you have exposed a cluster workload to the tailnet using Tailscale Ingress and you also want to refer to the workload from within the cluster over the Ingress''s MagicDNS name (usually because you have some callback component that needs to use the same URL as that used by a non-cluster client on tailnet). When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will deploy a nameserver for ts.net DNS names and automatically populate it with records for any Tailscale egress or Ingress proxies deployed to that cluster. Currently you must manually update your cluster DNS configuration to add the IP address of the deployed nameserver as a ts.net stub nameserver. Instructions for how to do it: https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). Tailscale Kubernetes operator will write the address of a Service fronting the nameserver to dsnconfig.status.nameserver.ip. DNSConfig is a singleton - you must not create more than one. NB: if you want cluster workloads to be able to refer to Tailscale Ingress using its MagicDNS name, you must also annotate the Ingress resource with tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to ensure that the proxy created for the Ingress listens on its Pod IP address. NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.'
description: |-
DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS
names resolvable by cluster workloads. Use this if: A) you need to refer to
tailnet services, exposed to cluster via Tailscale Kubernetes operator egress
proxies by the MagicDNS names of those tailnet services (usually because the
services run over HTTPS)
B) you have exposed a cluster workload to the tailnet using Tailscale Ingress
and you also want to refer to the workload from within the cluster over the
Ingress's MagicDNS name (usually because you have some callback component
that needs to use the same URL as that used by a non-cluster client on
tailnet).
When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will
deploy a nameserver for ts.net DNS names and automatically populate it with records
for any Tailscale egress or Ingress proxies deployed to that cluster.
Currently you must manually update your cluster DNS configuration to add the
IP address of the deployed nameserver as a ts.net stub nameserver.
Instructions for how to do it:
https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS),
https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns).
Tailscale Kubernetes operator will write the address of a Service fronting
the nameserver to dsnconfig.status.nameserver.ip.
DNSConfig is a singleton - you must not create more than one.
NB: if you want cluster workloads to be able to refer to Tailscale Ingress
using its MagicDNS name, you must also annotate the Ingress resource with
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
ensure that the proxy created for the Ingress listens on its Pod IP address.
NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.
type: object
required:
- spec
properties:
apiVersion:
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: 'Spec describes the desired DNS configuration. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
description: |-
Spec describes the desired DNS configuration.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
type: object
required:
- nameserver
properties:
nameserver:
description: Configuration for a nameserver that can resolve ts.net DNS names associated with in-cluster proxies for Tailscale egress Services and Tailscale Ingresses. The operator will always deploy this nameserver when a DNSConfig is applied.
description: |-
Configuration for a nameserver that can resolve ts.net DNS names
associated with in-cluster proxies for Tailscale egress Services and
Tailscale Ingresses. The operator will always deploy this nameserver
when a DNSConfig is applied.
type: object
properties:
image:
@@ -57,38 +99,66 @@ spec:
description: Tag defaults to operator's own tag.
type: string
status:
description: Status describes the status of the DNSConfig. This is set and managed by the Tailscale operator.
description: |-
Status describes the status of the DNSConfig. This is set
and managed by the Tailscale operator.
type: object
properties:
conditions:
type: array
items:
description: ConnectorCondition contains condition information for a Connector.
description: Condition contains details for one aspect of the current state of this API Resource.
type: object
required:
- lastTransitionTime
- message
- reason
- status
- type
properties:
lastTransitionTime:
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
type: string
format: date-time
message:
description: Message is a human readable description of the details of the last transition, complementing reason.
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
type: string
maxLength: 32768
observedGeneration:
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
type: integer
format: int64
minimum: 0
reason:
description: Reason is a brief machine readable explanation for the condition's last transition.
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
type: string
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
status:
description: Status of the condition, one of ('True', 'False', 'Unknown').
description: status of the condition, one of True, False, Unknown.
type: string
enum:
- "True"
- "False"
- Unknown
type:
description: Type of the condition, known values are (`SubnetRouterReady`).
description: type of condition in CamelCase or in foo.example.com/CamelCase.
type: string
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
@@ -97,7 +167,13 @@ spec:
type: object
properties:
ip:
description: IP is the ClusterIP of the Service fronting the deployed ts.net nameserver. Currently you must manually update your cluster DNS config to add this address as a stub nameserver for ts.net for cluster workloads to be able to resolve MagicDNS names associated with egress or Ingress proxies. The IP address will change if you delete and recreate the DNSConfig.
description: |-
IP is the ClusterIP of the Service fronting the deployed ts.net nameserver.
Currently you must manually update your cluster DNS config to add
this address as a stub nameserver for ts.net for cluster workloads to be
able to resolve MagicDNS names associated with egress or Ingress
proxies.
The IP address will change if you delete and recreate the DNSConfig.
type: string
served: true
storage: true

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,6 @@
//go:build !plan9
// tailscale-operator provides a way to expose services running in a Kubernetes
// cluster to your Tailnet and to make Tailscale nodes available to cluster
// workloads
package main
import (
@@ -27,6 +24,7 @@ import (
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
const (
@@ -170,36 +168,49 @@ func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessS
}
}
// Get the Pod IP addresses for the proxy from the EndpointSlice for the
// headless Service.
// Get the Pod IP addresses for the proxy from the EndpointSlices for
// the headless Service. The Service can have multiple EndpointSlices
// associated with it, for example in dual-stack clusters.
labels := map[string]string{discoveryv1.LabelServiceName: headlessSvc.Name} // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, dnsRR.Client, dnsRR.tsNamespace, labels)
if err != nil {
return fmt.Errorf("error getting the EndpointSlice for the proxy's headless Service: %w", err)
var eps = new(discoveryv1.EndpointSliceList)
if err := dnsRR.List(ctx, eps, client.InNamespace(dnsRR.tsNamespace), client.MatchingLabels(labels)); err != nil {
return fmt.Errorf("error listing EndpointSlices for the proxy's headless Service: %w", err)
}
if eps == nil {
if len(eps.Items) == 0 {
logger.Debugf("proxy's headless Service EndpointSlice does not yet exist. We will reconcile again once it's created")
return nil
}
// An EndpointSlice for a Service can have a list of endpoints that each
// Each EndpointSlice for a Service can have a list of endpoints that each
// can have multiple addresses - these are the IP addresses of any Pods
// selected by that Service. Pick all the IPv4 addresses.
ips := make([]string, 0)
for _, ep := range eps.Endpoints {
for _, ip := range ep.Addresses {
if !net.IsIPv4String(ip) {
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
} else {
ips = append(ips, ip)
// It is also possible that multiple EndpointSlices have overlapping addresses.
// https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#duplicate-endpoints
ips := make(set.Set[string], 0)
for _, slice := range eps.Items {
if slice.AddressType != discoveryv1.AddressTypeIPv4 {
logger.Infof("EndpointSlice is for AddressType %s, currently only IPv4 address type is supported", slice.AddressType)
continue
}
for _, ep := range slice.Endpoints {
if !epIsReady(&ep) {
logger.Debugf("Endpoint with addresses %v appears not ready to receive traffic %v", ep.Addresses, ep.Conditions.String())
continue
}
for _, ip := range ep.Addresses {
if !net.IsIPv4String(ip) {
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
} else {
ips.Add(ip)
}
}
}
}
if len(ips) == 0 {
if ips.Len() == 0 {
logger.Debugf("EndpointSlice for the Service contains no IPv4 addresses. We will reconcile again once they are created.")
return nil
}
updateFunc := func(rec *operatorutils.Records) {
mak.Set(&rec.IP4, fqdn, ips)
mak.Set(&rec.IP4, fqdn, ips.Slice())
}
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error updating DNS records: %w", err)
@@ -207,6 +218,17 @@ func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessS
return nil
}
// epIsReady reports whether the endpoint is currently in a state to receive new
// traffic. As per kube docs, only explicitly set 'false' for 'Ready' or
// 'Serving' conditions or explicitly set 'true' for 'Terminating' condition
// means that the Endpoint is NOT ready.
// https://github.com/kubernetes/kubernetes/blob/60c4c2b2521fb454ce69dee737e3eb91a25e0535/pkg/apis/discovery/types.go#L109-L131
func epIsReady(ep *discoveryv1.Endpoint) bool {
return (ep.Conditions.Ready == nil || *ep.Conditions.Ready) &&
(ep.Conditions.Serving == nil || *ep.Conditions.Serving) &&
(ep.Conditions.Terminating == nil || !*ep.Conditions.Terminating)
}
// maybeCleanup ensures that the DNS record for the proxy has been removed from
// dnsrecords ConfigMap and the tailscale.com/dns-records-reconciler finalizer
// has been removed from the Service. If the record is not found in the

View File

@@ -8,6 +8,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
@@ -87,13 +88,16 @@ func TestDNSRecordsReconciler(t *testing.T) {
},
}
headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service
ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7")
ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7", discoveryv1.AddressTypeIPv4)
epv6 := endpointSliceForService(headlessForEgressSvcFQDN, "2600:1900:4011:161:0:d:0:d", discoveryv1.AddressTypeIPv6)
mustCreate(t, fc, egressSvcFQDN)
mustCreate(t, fc, headlessForEgressSvcFQDN)
mustCreate(t, fc, ep)
mustCreate(t, fc, epv6)
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
// ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7
wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}}
wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}} // IPv6 endpoint is currently ignored
expectHostsRecords(t, fc, wantHosts)
// 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's
@@ -106,7 +110,7 @@ func TestDNSRecordsReconciler(t *testing.T) {
expectHostsRecords(t, fc, wantHosts)
// 3. DNS record is updated if the IP address of the proxy Pod changes.
ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4")
ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4", discoveryv1.AddressTypeIPv4)
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
ep.Endpoints[0].Addresses = []string{"10.6.5.4"}
})
@@ -116,7 +120,7 @@ func TestDNSRecordsReconciler(t *testing.T) {
// 4. DNS record is created for an ingress proxy configured via Ingress
headlessForIngress := headlessSvcForParent(ing, "ingress")
ep = endpointSliceForService(headlessForIngress, "10.9.8.7")
ep = endpointSliceForService(headlessForIngress, "10.9.8.7", discoveryv1.AddressTypeIPv4)
mustCreate(t, fc, headlessForIngress)
mustCreate(t, fc, ep)
expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
@@ -140,6 +144,17 @@ func TestDNSRecordsReconciler(t *testing.T) {
expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"}
expectHostsRecords(t, fc, wantHosts)
// 7. A not-ready Endpoint is removed from DNS config.
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
ep.Endpoints[0].Conditions.Ready = ptr.To(false)
ep.Endpoints = append(ep.Endpoints, discoveryv1.Endpoint{
Addresses: []string{"1.2.3.4"},
})
})
expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
wantHosts["another.ingress.ts.net"] = []string{"1.2.3.4"}
expectHostsRecords(t, fc, wantHosts)
}
func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
@@ -162,15 +177,21 @@ func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
}
}
func endpointSliceForService(svc *corev1.Service, ip string) *discoveryv1.EndpointSlice {
func endpointSliceForService(svc *corev1.Service, ip string, fam discoveryv1.AddressType) *discoveryv1.EndpointSlice {
return &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: svc.Name,
Name: fmt.Sprintf("%s-%s", svc.Name, string(fam)),
Namespace: svc.Namespace,
Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name},
},
AddressType: fam,
Endpoints: []discoveryv1.Endpoint{{
Addresses: []string{ip},
Conditions: discoveryv1.EndpointConditions{
Ready: ptr.To(true),
Serving: ptr.To(true),
Terminating: ptr.To(false),
},
}},
}
}

View File

@@ -3,6 +3,7 @@
//go:build !plan9
// The generate command creates tailscale.com CRDs.
package main
import (

View File

@@ -248,9 +248,9 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
// created proxy resources.
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
pc.Status = tsapi.ProxyClassStatus{
Conditions: []tsapi.ConnectorCondition{{
Conditions: []metav1.Condition{{
Status: metav1.ConditionTrue,
Type: tsapi.ProxyClassready,
Type: string(tsapi.ProxyClassready),
ObservedGeneration: pc.Generation,
}}}
})

View File

@@ -101,7 +101,7 @@ func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Requ
}
oldCnStatus := dnsCfg.Status.DeepCopy()
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger)
if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) {
// An error encountered here should get returned by the Reconcile function.

View File

@@ -81,12 +81,12 @@ func TestNameserverReconciler(t *testing.T) {
IP: "1.2.3.4",
}
dnsCfg.Finalizers = []string{FinalizerName}
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, tsapi.ConnectorCondition{
Type: tsapi.NameserverReady,
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, metav1.Condition{
Type: string(tsapi.NameserverReady),
Status: metav1.ConditionTrue,
Reason: reasonNameserverCreated,
Message: reasonNameserverCreated,
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
})
expectEqual(t, fc, dnsCfg, nil)

View File

@@ -51,8 +51,8 @@ import (
// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart.
//go:generate go run tailscale.com/cmd/k8s-operator/generate staticmanifests
// Generate CRD docs from the yamls
//go:generate go run fybrik.io/crdoc --resources=./deploy/crds --output=../../k8s-operator/api.md
// Generate CRD API docs.
//go:generate go run github.com/elastic/crd-ref-docs --renderer=markdown --source-path=../../k8s-operator/apis/ --config=../../k8s-operator/api-docs-config.yaml --output-path=../../k8s-operator/api.md
func main() {
// Required to use our client API. We're fine with the instability since the
@@ -278,6 +278,7 @@ func runReconcilers(opts reconcilerOpts) {
isDefaultLoadBalancer: opts.proxyActAsDefaultLoadBalancer,
recorder: eventRecorder,
tsNamespace: opts.tailscaleNamespace,
clock: tstime.DefaultClock{},
})
if err != nil {
startlog.Fatalf("could not create service reconciler: %v", err)

View File

@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"go.uber.org/zap"
@@ -17,10 +18,13 @@ import (
networkingv1 "k8s.io/api/networking/v1"
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/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/net/dns/resolvconffile"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/types/ptr"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
@@ -33,6 +37,7 @@ func TestLoadBalancerClass(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -42,11 +47,13 @@ func TestLoadBalancerClass(t *testing.T) {
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
logger: zl.Sugar(),
clock: clock,
recorder: record.NewFakeRecorder(100),
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
// Create a service that we should manage, but start with a miconfiguration
// in the annotations.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
@@ -55,6 +62,9 @@ func TestLoadBalancerClass(t *testing.T) {
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetFQDN: "invalid.example.com",
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
@@ -65,6 +75,46 @@ func TestLoadBalancerClass(t *testing.T) {
expectReconciled(t, sr, "default", "test")
// The expected value of .status.conditions[0].LastTransitionTime until the
// proxy becomes ready.
t0 := conditionTime(clock)
// Should have an error about invalid config.
want := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetFQDN: "invalid.example.com",
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeLoadBalancer,
LoadBalancerClass: ptr.To("tailscale"),
},
Status: corev1.ServiceStatus{
Conditions: []metav1.Condition{{
Type: string(tsapi.ProxyReady),
Status: metav1.ConditionFalse,
LastTransitionTime: t0,
Reason: reasonProxyInvalid,
Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-fqdn: "invalid.example.com" does not appear to be a valid MagicDNS name`,
}},
},
}
expectEqual(t, fc, want, nil)
// Delete the misconfiguration so the proxy starts getting created on the
// next reconcile.
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.ObjectMeta.Annotations = nil
})
clock.Advance(time.Second)
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
opts := configOpts{
stsName: shortName,
@@ -79,6 +129,19 @@ func TestLoadBalancerClass(t *testing.T) {
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
want.Annotations = nil
want.ObjectMeta.Finalizers = []string{"tailscale.com/finalizer"}
want.Status = corev1.ServiceStatus{
Conditions: []metav1.Condition{{
Type: string(tsapi.ProxyReady),
Status: metav1.ConditionFalse,
LastTransitionTime: t0, // Status is still false, no update to transition time
Reason: reasonProxyPending,
Message: "no Tailscale hostname known yet, waiting for proxy pod to finish auth",
}},
}
expectEqual(t, fc, want, nil)
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
// that we get to the end.
@@ -90,33 +153,16 @@ func TestLoadBalancerClass(t *testing.T) {
s.Data["device_fqdn"] = []byte("tailscale.device.name.")
s.Data["device_ips"] = []byte(`["100.99.98.97", "2c0a:8083:94d4:2012:3165:34a5:3616:5fdf"]`)
})
clock.Advance(time.Second)
expectReconciled(t, sr, "default", "test")
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
Finalizers: []string{"tailscale.com/finalizer"},
UID: types.UID("1234-UID"),
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeLoadBalancer,
LoadBalancerClass: ptr.To("tailscale"),
},
Status: corev1.ServiceStatus{
LoadBalancer: corev1.LoadBalancerStatus{
Ingress: []corev1.LoadBalancerIngress{
{
Hostname: "tailscale.device.name",
},
{
IP: "100.99.98.97",
},
},
want.Status.Conditions = proxyCreatedCondition(clock)
want.Status.LoadBalancer = corev1.LoadBalancerStatus{
Ingress: []corev1.LoadBalancerIngress{
{
Hostname: "tailscale.device.name",
},
{
IP: "100.99.98.97",
},
},
}
@@ -144,11 +190,9 @@ func TestLoadBalancerClass(t *testing.T) {
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
// Note that the Tailscale-specific condition status should be gone now.
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -170,6 +214,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
t.Fatal(err)
}
tailnetTargetFQDN := "foo.bar.ts.net."
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -180,6 +225,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
}
// Create a service that we should manage, and check that the initial round
@@ -220,10 +266,6 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -238,6 +280,9 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
Type: corev1.ServiceTypeExternalName,
Selector: nil,
},
Status: corev1.ServiceStatus{
Conditions: proxyCreatedCondition(clock),
},
}
expectEqual(t, fc, want, nil)
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
@@ -280,6 +325,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
t.Fatal(err)
}
tailnetTargetIP := "100.66.66.66"
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -290,6 +336,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
}
// Create a service that we should manage, and check that the initial round
@@ -330,10 +377,6 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -348,6 +391,9 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
Type: corev1.ServiceTypeExternalName,
Selector: nil,
},
Status: corev1.ServiceStatus{
Conditions: proxyCreatedCondition(clock),
},
}
expectEqual(t, fc, want, nil)
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
@@ -389,6 +435,7 @@ func TestAnnotations(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -399,6 +446,7 @@ func TestAnnotations(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
}
// Create a service that we should manage, and check that the initial round
@@ -437,10 +485,6 @@ func TestAnnotations(t *testing.T) {
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -454,6 +498,9 @@ func TestAnnotations(t *testing.T) {
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
Status: corev1.ServiceStatus{
Conditions: proxyCreatedCondition(clock),
},
}
expectEqual(t, fc, want, nil)
@@ -473,10 +520,6 @@ func TestAnnotations(t *testing.T) {
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -497,6 +540,7 @@ func TestAnnotationIntoLB(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -507,6 +551,7 @@ func TestAnnotationIntoLB(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
}
// Create a service that we should manage, and check that the initial round
@@ -558,10 +603,6 @@ func TestAnnotationIntoLB(t *testing.T) {
})
expectReconciled(t, sr, "default", "test")
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -575,6 +616,9 @@ func TestAnnotationIntoLB(t *testing.T) {
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
Status: corev1.ServiceStatus{
Conditions: proxyCreatedCondition(clock),
},
}
expectEqual(t, fc, want, nil)
@@ -592,10 +636,6 @@ func TestAnnotationIntoLB(t *testing.T) {
// ... but the service should have a LoadBalancer status.
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -618,6 +658,7 @@ func TestAnnotationIntoLB(t *testing.T) {
},
},
},
Conditions: proxyCreatedCondition(clock),
},
}
expectEqual(t, fc, want, nil)
@@ -630,6 +671,7 @@ func TestLBIntoAnnotation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -640,6 +682,7 @@ func TestLBIntoAnnotation(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
}
// Create a service that we should manage, and check that the initial round
@@ -689,10 +732,6 @@ func TestLBIntoAnnotation(t *testing.T) {
})
expectReconciled(t, sr, "default", "test")
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -715,6 +754,7 @@ func TestLBIntoAnnotation(t *testing.T) {
},
},
},
Conditions: proxyCreatedCondition(clock),
},
}
expectEqual(t, fc, want, nil)
@@ -740,10 +780,6 @@ func TestLBIntoAnnotation(t *testing.T) {
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -757,6 +793,9 @@ func TestLBIntoAnnotation(t *testing.T) {
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
Status: corev1.ServiceStatus{
Conditions: proxyCreatedCondition(clock),
},
}
expectEqual(t, fc, want, nil)
}
@@ -768,6 +807,7 @@ func TestCustomHostname(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -778,6 +818,7 @@ func TestCustomHostname(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
}
// Create a service that we should manage, and check that the initial round
@@ -817,10 +858,6 @@ func TestCustomHostname(t *testing.T) {
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -835,6 +872,9 @@ func TestCustomHostname(t *testing.T) {
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
Status: corev1.ServiceStatus{
Conditions: proxyCreatedCondition(clock),
},
}
expectEqual(t, fc, want, nil)
@@ -854,10 +894,6 @@ func TestCustomHostname(t *testing.T) {
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
@@ -881,6 +917,7 @@ func TestCustomPriorityClassName(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -892,6 +929,7 @@ func TestCustomPriorityClassName(t *testing.T) {
proxyPriorityClassName: "custom-priority-class-name",
},
logger: zl.Sugar(),
clock: clock,
}
// Create a service that we should manage, and check that the initial round
@@ -954,6 +992,7 @@ func TestProxyClassForService(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -964,6 +1003,7 @@ func TestProxyClassForService(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
}
// 1. A new tailscale LoadBalancer Service is created without any
@@ -1012,9 +1052,9 @@ func TestProxyClassForService(t *testing.T) {
// applied to the proxy resources.
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
pc.Status = tsapi.ProxyClassStatus{
Conditions: []tsapi.ConnectorCondition{{
Conditions: []metav1.Condition{{
Status: metav1.ConditionTrue,
Type: tsapi.ProxyClassready,
Type: string(tsapi.ProxyClassready),
ObservedGeneration: pc.Generation,
}}}
})
@@ -1041,6 +1081,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -1051,6 +1092,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
isDefaultLoadBalancer: true,
}
@@ -1095,6 +1137,7 @@ func TestProxyFirewallMode(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -1106,6 +1149,7 @@ func TestProxyFirewallMode(t *testing.T) {
tsFirewallMode: "nftables",
},
logger: zl.Sugar(),
clock: clock,
isDefaultLoadBalancer: true,
}
@@ -1148,6 +1192,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -1158,6 +1203,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
isDefaultLoadBalancer: true,
}
@@ -1439,6 +1485,7 @@ func Test_externalNameService(t *testing.T) {
// 1. A External name Service that should be exposed via Tailscale gets
// created.
clock := tstest.NewClock(tstest.ClockOpts{})
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -1449,6 +1496,7 @@ func Test_externalNameService(t *testing.T) {
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
clock: clock,
}
// 1. Create an ExternalName Service that we should manage, and check that the initial round
@@ -1504,3 +1552,18 @@ func toFQDN(t *testing.T, s string) dnsname.FQDN {
}
return fqdn
}
func proxyCreatedCondition(clock tstime.Clock) []metav1.Condition {
return []metav1.Condition{{
Type: string(tsapi.ProxyReady),
Status: metav1.ConditionTrue,
ObservedGeneration: 0,
LastTransitionTime: conditionTime(clock),
Reason: reasonProxyCreated,
Message: reasonProxyCreated,
}}
}
func conditionTime(clock tstime.Clock) metav1.Time {
return metav1.NewTime(clock.Now().Truncate(time.Second))
}

View File

@@ -11,15 +11,18 @@ import (
"log"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"strings"
"github.com/pkg/errors"
"go.uber.org/zap"
"k8s.io/client-go/rest"
"k8s.io/client-go/transport"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
ksr "tailscale.com/k8s-operator/sessionrecording"
tskube "tailscale.com/kube"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
@@ -30,10 +33,26 @@ import (
var whoIsKey = ctxkey.New("", (*apitype.WhoIsResponse)(nil))
var counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied")
var (
// counterNumRequestsproxies counts the number of API server requests proxied via this proxy.
counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied")
)
type apiServerProxyMode int
func (a apiServerProxyMode) String() string {
switch a {
case apiserverProxyModeDisabled:
return "disabled"
case apiserverProxyModeEnabled:
return "auth"
case apiserverProxyModeNoAuth:
return "noauth"
default:
return "unknown"
}
}
const (
apiserverProxyModeDisabled apiServerProxyMode = iota
apiserverProxyModeEnabled
@@ -97,26 +116,7 @@ func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config,
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode)
}
// apiserverProxy is an http.Handler that authenticates requests using the Tailscale
// LocalAPI and then proxies them to the Kubernetes API.
type apiserverProxy struct {
log *zap.SugaredLogger
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
}
func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
who, err := h.lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
h.log.Errorf("failed to authenticate caller: %v", err)
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
return
}
counterNumRequestsProxied.Add(1)
h.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode, restConfig.Host)
}
// runAPIServerProxy runs an HTTP server that authenticates requests using the
@@ -133,64 +133,43 @@ func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// are passed through to the Kubernetes API.
//
// It never returns.
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode) {
func runAPIServerProxy(ts *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode, host string) {
if mode == apiserverProxyModeDisabled {
return
}
ln, err := s.Listen("tcp", ":443")
ln, err := ts.Listen("tcp", ":443")
if err != nil {
log.Fatalf("could not listen on :443: %v", err)
}
u, err := url.Parse(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
u, err := url.Parse(host)
if err != nil {
log.Fatalf("runAPIServerProxy: failed to parse URL %v", err)
}
lc, err := s.LocalClient()
lc, err := ts.LocalClient()
if err != nil {
log.Fatalf("could not get local client: %v", err)
}
ap := &apiserverProxy{
log: log,
lc: lc,
rp: &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
// Replace the URL with the Kubernetes APIServer.
r.Out.URL.Scheme = u.Scheme
r.Out.URL.Host = u.Host
if mode == apiserverProxyModeNoAuth {
// If we are not providing authentication, then we are just
// proxying to the Kubernetes API, so we don't need to do
// anything else.
return
}
// We want to proxy to the Kubernetes API, but we want to use
// the caller's identity to do so. We do this by impersonating
// the caller using the Kubernetes User Impersonation feature:
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
// Out of paranoia, remove all authentication headers that might
// have been set by the client.
r.Out.Header.Del("Authorization")
r.Out.Header.Del("Impersonate-Group")
r.Out.Header.Del("Impersonate-User")
r.Out.Header.Del("Impersonate-Uid")
for k := range r.Out.Header {
if strings.HasPrefix(k, "Impersonate-Extra-") {
r.Out.Header.Del(k)
}
}
// Now add the impersonation headers that we want.
if err := addImpersonationHeaders(r.Out, log); err != nil {
panic("failed to add impersonation headers: " + err.Error())
}
},
Transport: rt,
},
log: log,
lc: lc,
mode: mode,
upstreamURL: u,
ts: ts,
}
ap.rp = &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
ap.addImpersonationHeadersAsRequired(pr.Out)
},
Transport: rt,
}
mux := http.NewServeMux()
mux.HandleFunc("/", ap.serveDefault)
mux.HandleFunc("POST /api/v1/namespaces/{namespace}/pods/{pod}/exec", ap.serveExecSPDY)
mux.HandleFunc("GET /api/v1/namespaces/{namespace}/pods/{pod}/exec", ap.serveExecWS)
hs := &http.Server{
// Kubernetes uses SPDY for exec and port-forward, however SPDY is
// incompatible with HTTP/2; so disable HTTP/2 in the proxy.
@@ -199,14 +178,145 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLo
NextProtos: []string{"http/1.1"},
},
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
Handler: ap,
Handler: mux,
}
log.Infof("listening on %s", ln.Addr())
log.Infof("API server proxy in %q mode is listening on %s", mode, ln.Addr())
if err := hs.ServeTLS(ln, "", ""); err != nil {
log.Fatalf("runAPIServerProxy: failed to serve %v", err)
}
}
// apiserverProxy is an [net/http.Handler] that authenticates requests using the Tailscale
// LocalAPI and then proxies them to the Kubernetes API.
type apiserverProxy struct {
log *zap.SugaredLogger
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
mode apiServerProxyMode
ts *tsnet.Server
upstreamURL *url.URL
}
// serveDefault is the default handler for Kubernetes API server requests.
func (ap *apiserverProxy) serveDefault(w http.ResponseWriter, r *http.Request) {
who, err := ap.whoIs(r)
if err != nil {
ap.authError(w, err)
return
}
counterNumRequestsProxied.Add(1)
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
}
// serveExecSPDY serves 'kubectl exec' requests for sessions streamed over SPDY,
// optionally configuring the kubectl exec sessions to be recorded.
func (ap *apiserverProxy) serveExecSPDY(w http.ResponseWriter, r *http.Request) {
ap.execForProto(w, r, ksr.SPDYProtocol)
}
// serveExecWS serves 'kubectl exec' requests for sessions streamed over WebSocket,
// optionally configuring the kubectl exec sessions to be recorded.
func (ap *apiserverProxy) serveExecWS(w http.ResponseWriter, r *http.Request) {
ap.execForProto(w, r, ksr.WSProtocol)
}
func (ap *apiserverProxy) execForProto(w http.ResponseWriter, r *http.Request, proto ksr.Protocol) {
who, err := ap.whoIs(r)
if err != nil {
ap.authError(w, err)
return
}
counterNumRequestsProxied.Add(1)
failOpen, addrs, err := determineRecorderConfig(who)
if err != nil {
ap.log.Errorf("error trying to determine whether the 'kubectl exec' session needs to be recorded: %v", err)
return
}
if failOpen && len(addrs) == 0 { // will not record
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
return
}
ksr.CounterSessionRecordingsAttempted.Add(1) // at this point we know that users intended for this session to be recorded
if !failOpen && len(addrs) == 0 {
msg := "forbidden: 'kubectl exec' session must be recorded, but no recorders are available."
ap.log.Error(msg)
http.Error(w, msg, http.StatusForbidden)
return
}
wantsHeader := upgradeHeaderForProto[proto]
if h := r.Header.Get("Upgrade"); 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."
ap.log.Warn(msg)
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
return
}
ap.log.Error(msg)
msg += "; failure mode is 'fail closed'; closing connection."
http.Error(w, msg, http.StatusForbidden)
return
}
opts := ksr.HijackerOpts{
Req: r,
W: w,
Proto: proto,
TS: ap.ts,
Who: who,
Addrs: addrs,
FailOpen: failOpen,
Pod: r.PathValue("pod"),
Namespace: r.PathValue("namespace"),
Log: ap.log,
}
h := ksr.New(opts)
ap.rp.ServeHTTP(h, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
}
func (h *apiserverProxy) addImpersonationHeadersAsRequired(r *http.Request) {
r.URL.Scheme = h.upstreamURL.Scheme
r.URL.Host = h.upstreamURL.Host
if h.mode == apiserverProxyModeNoAuth {
// If we are not providing authentication, then we are just
// proxying to the Kubernetes API, so we don't need to do
// anything else.
return
}
// We want to proxy to the Kubernetes API, but we want to use
// the caller's identity to do so. We do this by impersonating
// the caller using the Kubernetes User Impersonation feature:
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
// Out of paranoia, remove all authentication headers that might
// have been set by the client.
r.Header.Del("Authorization")
r.Header.Del("Impersonate-Group")
r.Header.Del("Impersonate-User")
r.Header.Del("Impersonate-Uid")
for k := range r.Header {
if strings.HasPrefix(k, "Impersonate-Extra-") {
r.Header.Del(k)
}
}
// Now add the impersonation headers that we want.
if err := addImpersonationHeaders(r, h.log); err != nil {
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)
}
const (
// oldCapabilityName is a legacy form of
// tailfcg.PeerCapabilityKubernetes capability. The only capability rule
@@ -266,3 +376,39 @@ func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
}
return nil
}
// determineRecorderConfig determines recorder config from requester's peer
// capabilities. Determines whether a 'kubectl exec' session from this requester
// needs to be recorded and what recorders the recording should be sent to.
func determineRecorderConfig(who *apitype.WhoIsResponse) (failOpen bool, recorderAddresses []netip.AddrPort, _ error) {
if who == nil {
return false, nil, errors.New("[unexpected] cannot determine caller")
}
failOpen = true
rules, err := tailcfg.UnmarshalCapJSON[tskube.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
if err != nil {
return failOpen, nil, fmt.Errorf("failed to unmarshal Kubernetes capability: %w", err)
}
if len(rules) == 0 {
return failOpen, nil, nil
}
for _, rule := range rules {
if len(rule.RecorderAddrs) != 0 {
// TODO (irbekrm): here or later determine if the
// recorders behind those addrs are online - else we
// spend 30s trying to reach a recorder whose tailscale
// status is offline.
recorderAddresses = append(recorderAddresses, rule.RecorderAddrs...)
}
if rule.EnforceRecorder {
failOpen = false
}
}
return failOpen, recorderAddresses, nil
}
var upgradeHeaderForProto = map[ksr.Protocol]string{
ksr.SPDYProtocol: "SPDY/3.1",
ksr.WSProtocol: "websocket",
}

View File

@@ -7,6 +7,8 @@ package main
import (
"net/http"
"net/netip"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
@@ -126,3 +128,72 @@ func TestImpersonationHeaders(t *testing.T) {
}
}
}
func Test_determineRecorderConfig(t *testing.T) {
addr1, addr2 := netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"), netip.MustParseAddrPort("100.99.99.99:80")
tests := []struct {
name string
wantFailOpen bool
wantRecorderAddresses []netip.AddrPort
who *apitype.WhoIsResponse
}{
{
name: "two_ips_fail_closed",
who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80","100.99.99.99:80"],"enforceRecorder":true}`}}),
wantRecorderAddresses: []netip.AddrPort{addr1, addr2},
},
{
name: "two_ips_fail_open",
who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80","100.99.99.99:80"]}`}}),
wantRecorderAddresses: []netip.AddrPort{addr1, addr2},
wantFailOpen: true,
},
{
name: "odd_rule_combination_fail_closed",
who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["100.99.99.99:80"],"enforceRecorder":false}`, `{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"]}`, `{"enforceRecorder":true,"impersonate":{"groups":["system:masters"]}}`}}),
wantRecorderAddresses: []netip.AddrPort{addr2, addr1},
},
{
name: "no_caps",
who: whoResp(map[string][]string{}),
wantFailOpen: true,
},
{
name: "no_recorder_caps",
who: whoResp(map[string][]string{"foo": {`{"x":"y"}`}, string(tailcfg.PeerCapabilityKubernetes): {`{"impersonate":{"groups":["system:masters"]}}`}}),
wantFailOpen: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotFailOpen, gotRecorderAddresses, err := determineRecorderConfig(tt.who)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotFailOpen != tt.wantFailOpen {
t.Errorf("determineRecorderConfig() gotFailOpen = %v, want %v", gotFailOpen, tt.wantFailOpen)
}
if !reflect.DeepEqual(gotRecorderAddresses, tt.wantRecorderAddresses) {
t.Errorf("determineRecorderConfig() gotRecorderAddresses = %v, want %v", gotRecorderAddresses, tt.wantRecorderAddresses)
}
})
}
}
func whoResp(capMap map[string][]string) *apitype.WhoIsResponse {
resp := &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{},
}
for cap, rules := range capMap {
resp.CapMap[tailcfg.PeerCapability(cap)] = raw(rules...)
}
return resp
}
func raw(in ...string) []tailcfg.RawMessage {
var out []tailcfg.RawMessage
for _, i := range in {
out = append(out, tailcfg.RawMessage(i))
}
return out
}

View File

@@ -8,7 +8,9 @@ package main
import (
"context"
"fmt"
"slices"
"strings"
"sync"
dockerref "github.com/distribution/reference"
"go.uber.org/zap"
@@ -18,6 +20,7 @@ import (
apivalidation "k8s.io/apimachinery/pkg/api/validation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
metavalidation "k8s.io/apimachinery/pkg/apis/meta/v1/validation"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -25,6 +28,8 @@ import (
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
const (
@@ -41,8 +46,20 @@ type ProxyClassReconciler struct {
recorder record.EventRecorder
logger *zap.SugaredLogger
clock tstime.Clock
mu sync.Mutex // protects following
// managedProxyClasses is a set of all ProxyClass resources that we're currently
// managing. This is only used for metrics.
managedProxyClasses set.Slice[types.UID]
}
var (
// gaugeProxyClassResources tracks the number of ProxyClass resources
// that we're currently managing.
gaugeProxyClassResources = clientmetric.NewGauge("k8s_proxyclass_resources")
)
func (pcr *ProxyClassReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
logger := pcr.logger.With("ProxyClass", req.Name)
logger.Debugf("starting reconcile")
@@ -57,9 +74,26 @@ func (pcr *ProxyClassReconciler) Reconcile(ctx context.Context, req reconcile.Re
return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com ProxyClass: %w", err)
}
if !pc.DeletionTimestamp.IsZero() {
logger.Debugf("ProxyClass is being deleted, do nothing")
return reconcile.Result{}, nil
logger.Debugf("ProxyClass is being deleted")
return reconcile.Result{}, pcr.maybeCleanup(ctx, logger, pc)
}
// Add a finalizer so that we can ensure that metrics get updated when
// this ProxyClass is deleted.
if !slices.Contains(pc.Finalizers, FinalizerName) {
logger.Debugf("updating ProxyClass finalizers")
pc.Finalizers = append(pc.Finalizers, FinalizerName)
if err := pcr.Update(ctx, pc); err != nil {
return res, fmt.Errorf("failed to add finalizer: %w", err)
}
}
// Ensure this ProxyClass is tracked in metrics.
pcr.mu.Lock()
pcr.managedProxyClasses.Add(pc.UID)
gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len()))
pcr.mu.Unlock()
oldPCStatus := pc.Status.DeepCopy()
if errs := pcr.validate(pc); errs != nil {
msg := fmt.Sprintf(messageProxyClassInvalid, errs.ToAggregate().Error())
@@ -77,7 +111,7 @@ func (pcr *ProxyClassReconciler) Reconcile(ctx context.Context, req reconcile.Re
return reconcile.Result{}, nil
}
func (a *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.ErrorList) {
func (pcr *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.ErrorList) {
if sts := pc.Spec.StatefulSet; sts != nil {
if len(sts.Labels) > 0 {
if errs := metavalidation.ValidateLabels(sts.Labels, field.NewPath(".spec.statefulSet.labels")); errs != nil {
@@ -103,13 +137,13 @@ func (a *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.
if tc := pod.TailscaleContainer; tc != nil {
for _, e := range tc.Env {
if strings.HasPrefix(string(e.Name), "TS_") {
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
}
if strings.EqualFold(string(e.Name), "EXPERIMENTAL_TS_CONFIGFILE_PATH") {
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
}
if strings.EqualFold(string(e.Name), "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS") {
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
}
}
if tc.Image != "" {
@@ -135,3 +169,27 @@ func (a *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.
// time.
return violations
}
// maybeCleanup removes tailscale.com finalizer and ensures that the ProxyClass
// is no longer counted towards k8s_proxyclass_resources.
func (pcr *ProxyClassReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, pc *tsapi.ProxyClass) error {
ix := slices.Index(pc.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
pcr.mu.Lock()
defer pcr.mu.Unlock()
pcr.managedProxyClasses.Remove(pc.UID)
gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len()))
return nil
}
pc.Finalizers = append(pc.Finalizers[:ix], pc.Finalizers[ix+1:]...)
if err := pcr.Update(ctx, pc); err != nil {
return fmt.Errorf("failed to remove finalizer: %w", err)
}
pcr.mu.Lock()
defer pcr.mu.Unlock()
pcr.managedProxyClasses.Remove(pc.UID)
gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len()))
logger.Infof("ProxyClass resources have been cleaned up")
return nil
}

View File

@@ -29,7 +29,8 @@ func TestProxyClass(t *testing.T) {
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
UID: types.UID("1234-UID"),
Finalizers: []string{"tailscale.com/finalizer"},
},
Spec: tsapi.ProxyClassSpec{
StatefulSet: &tsapi.StatefulSet{
@@ -67,12 +68,12 @@ func TestProxyClass(t *testing.T) {
// 1. A valid ProxyClass resource gets its status updated to Ready.
expectReconciled(t, pcr, "", "test")
pc.Status.Conditions = append(pc.Status.Conditions, tsapi.ConnectorCondition{
Type: tsapi.ProxyClassready,
pc.Status.Conditions = append(pc.Status.Conditions, metav1.Condition{
Type: string(tsapi.ProxyClassready),
Status: metav1.ConditionTrue,
Reason: reasonProxyClassValid,
Message: reasonProxyClassValid,
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
})
expectEqual(t, fc, pc, nil)

View File

@@ -35,7 +35,6 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"tailscale.com/types/ptr"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
)
@@ -295,6 +294,7 @@ func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, l
Selector: map[string]string{
"app": sts.ParentResourceUID,
},
IPFamilyPolicy: ptr.To(corev1.IPFamilyPolicyPreferDualStack),
},
}
logger.Debugf("reconciling headless service for StatefulSet")
@@ -406,8 +406,10 @@ func sanitizeConfigBytes(c ipn.ConfigVAlpha) string {
return string(sanitizedBytes)
}
// DeviceInfo returns the device ID and hostname for the Tailscale device
// associated with the given labels.
// DeviceInfo returns the device ID, hostname and IPs for the Tailscale device
// that acts as an operator proxy. It retrieves info from a Kubernetes Secret
// labeled with the provided labels.
// Either of device ID, hostname and IPs can be empty string if not found in the Secret.
func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map[string]string) (id tailcfg.StableNodeID, hostname string, ips []string, err error) {
sec, err := getSingleObject[corev1.Secret](ctx, a.Client, a.operatorNamespace, childLabels)
if err != nil {
@@ -424,7 +426,12 @@ func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map
// to remove it.
hostname = strings.TrimSuffix(string(sec.Data["device_fqdn"]), ".")
if hostname == "" {
return "", "", nil, nil
// Device ID gets stored and retrieved in a different flow than
// FQDN and IPs. A device that acts as Kubernetes operator
// proxy, but whose route setup has failed might have an device
// ID, but no FQDN/IPs. If so, return the ID, to allow the
// operator to clean up such devices.
return id, "", nil, nil
}
if rawDeviceIPs, ok := sec.Data["device_ips"]; ok {
if err := json.Unmarshal(rawDeviceIPs, &ips); err != nil {
@@ -940,14 +947,11 @@ func defaultEnv(envName, defVal string) string {
return v
}
func nameForService(svc *corev1.Service) (string, error) {
func nameForService(svc *corev1.Service) string {
if h, ok := svc.Annotations[AnnotationHostname]; ok {
if err := dnsname.ValidLabel(h); err != nil {
return "", fmt.Errorf("invalid Tailscale hostname %q: %w", h, err)
}
return h, nil
return h
}
return svc.Namespace + "-" + svc.Name, nil
return svc.Namespace + "-" + svc.Name
}
func isValidFirewallMode(m string) bool {

View File

@@ -7,6 +7,7 @@ package main
import (
"context"
"errors"
"fmt"
"net/netip"
"slices"
@@ -15,7 +16,9 @@ import (
"go.uber.org/zap"
corev1 "k8s.io/api/core/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"
@@ -23,13 +26,20 @@ import (
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/net/dns/resolvconffile"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
const (
resolvConfPath = "/etc/resolv.conf"
defaultClusterDomain = "cluster.local"
reasonProxyCreated = "ProxyCreated"
reasonProxyInvalid = "ProxyInvalid"
reasonProxyFailed = "ProxyFailed"
reasonProxyPending = "ProxyPending"
)
type ServiceReconciler struct {
@@ -50,6 +60,8 @@ type ServiceReconciler struct {
recorder record.EventRecorder
tsNamespace string
clock tstime.Clock
}
var (
@@ -76,6 +88,12 @@ func childResourceLabels(name, ns, typ string) map[string]string {
}
}
func (a *ServiceReconciler) isTailscaleService(svc *corev1.Service) bool {
targetIP := tailnetTargetAnnotation(svc)
targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]
return a.shouldExpose(svc) || targetIP != "" || targetFQDN != ""
}
func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
logger := a.logger.With("service-ns", req.Namespace, "service-name", req.Name)
logger.Debugf("starting reconcile")
@@ -90,9 +108,8 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
} else if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
}
targetIP := tailnetTargetAnnotation(svc)
targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && targetIP == "" && targetFQDN == "" {
if !svc.DeletionTimestamp.IsZero() || !a.isTailscaleService(svc) {
logger.Debugf("service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc)
}
@@ -104,7 +121,14 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
//
// This function is responsible for removing the finalizer from the service,
// once all associated resources are gone.
func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) (err error) {
oldSvcStatus := svc.Status.DeepCopy()
defer func() {
if !apiequality.Semantic.DeepEqual(oldSvcStatus, svc.Status) {
// An error encountered here should get returned by the Reconcile function.
err = errors.Join(err, a.Client.Status().Update(ctx, svc))
}
}()
ix := slices.Index(svc.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
@@ -114,6 +138,10 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
a.managedEgressProxies.Remove(svc.UID)
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
if !a.isTailscaleService(svc) {
tsoperator.RemoveServiceCondition(svc, tsapi.ProxyReady)
}
return nil
}
@@ -133,7 +161,7 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
// exactly once at the very end of cleanup, because the final step of
// cleanup removes the tailscale finalizer, which will make all future
// reconciles exit early.
logger.Infof("unexposed service from tailnet")
logger.Infof("unexposed Service from tailnet")
a.mu.Lock()
defer a.mu.Unlock()
@@ -141,6 +169,10 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
a.managedEgressProxies.Remove(svc.UID)
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
if !a.isTailscaleService(svc) {
tsoperator.RemoveServiceCondition(svc, tsapi.ProxyReady)
}
return nil
}
@@ -149,7 +181,15 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
//
// This function adds a finalizer to svc, ensuring that we can handle orderly
// deprovisioning later.
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) (err error) {
oldSvcStatus := svc.Status.DeepCopy()
defer func() {
if !apiequality.Semantic.DeepEqual(oldSvcStatus, svc.Status) {
// An error encountered here should get returned by the Reconcile function.
err = errors.Join(err, a.Client.Status().Update(ctx, svc))
}
}()
// Run for proxy config related validations here as opposed to running
// them earlier. This is to prevent cleanup being blocked on a
// misconfigured proxy param.
@@ -157,30 +197,31 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
msg := fmt.Sprintf("unable to provision proxy resources: invalid config: %v", err)
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDCONFIG", msg)
a.logger.Error(msg)
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyInvalid, msg, a.clock, logger)
return nil
}
if violations := validateService(svc); len(violations) > 0 {
msg := fmt.Sprintf("unable to provision proxy resources: invalid Service: %s", strings.Join(violations, ", "))
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVICE", msg)
a.logger.Error(msg)
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyInvalid, msg, a.clock, logger)
return nil
}
proxyClass := proxyClassForObject(svc)
if proxyClass != "" {
if ready, err := proxyClassIsReady(ctx, proxyClass, a.Client); err != nil {
return fmt.Errorf("error verifying ProxyClass for Service: %w", err)
errMsg := fmt.Errorf("error verifying ProxyClass for Service: %w", err)
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger)
return errMsg
} else if !ready {
logger.Infof("ProxyClass %s specified for the Service, but is not (yet) Ready, waiting..", proxyClass)
msg := fmt.Sprintf("ProxyClass %s specified for the Service, but is not (yet) Ready, waiting..", proxyClass)
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyPending, msg, a.clock, logger)
logger.Info(msg)
return nil
}
}
hostname, err := nameForService(svc)
if err != nil {
return err
}
if !slices.Contains(svc.Finalizers, FinalizerName) {
// This log line is printed exactly once during initial provisioning,
// because once the finalizer is in place this block gets skipped. So,
@@ -189,7 +230,9 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
logger.Infof("exposing service over tailscale")
svc.Finalizers = append(svc.Finalizers, FinalizerName)
if err := a.Update(ctx, svc); err != nil {
return fmt.Errorf("failed to add finalizer: %w", err)
errMsg := fmt.Errorf("failed to add finalizer: %w", err)
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger)
return errMsg
}
}
crl := childResourceLabels(svc.Name, svc.Namespace, "svc")
@@ -201,7 +244,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
sts := &tailscaleSTSConfig{
ParentResourceName: svc.Name,
ParentResourceUID: string(svc.UID),
Hostname: hostname,
Hostname: nameForService(svc),
Tags: tags,
ChildResourceLabels: crl,
ProxyClassName: proxyClass,
@@ -233,10 +276,12 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
var hsvc *corev1.Service
if hsvc, err = a.ssr.Provision(ctx, logger, sts); err != nil {
return fmt.Errorf("failed to provision: %w", err)
errMsg := fmt.Errorf("failed to provision: %w", err)
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger)
return errMsg
}
if sts.TailnetTargetIP != "" || sts.TailnetTargetFQDN != "" {
if sts.TailnetTargetIP != "" || sts.TailnetTargetFQDN != "" { // if an egress proxy
clusterDomain := retrieveClusterDomain(a.tsNamespace, logger)
headlessSvcName := hsvc.Name + "." + hsvc.Namespace + ".svc." + clusterDomain
if svc.Spec.ExternalName != headlessSvcName || svc.Spec.Type != corev1.ServiceTypeExternalName {
@@ -244,14 +289,18 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
svc.Spec.Selector = nil
svc.Spec.Type = corev1.ServiceTypeExternalName
if err := a.Update(ctx, svc); err != nil {
return fmt.Errorf("failed to update service: %w", err)
errMsg := fmt.Errorf("failed to update service: %w", err)
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger)
return errMsg
}
}
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger)
return nil
}
if !isTailscaleLoadBalancerService(svc, a.isDefaultLoadBalancer) {
logger.Debugf("service is not a LoadBalancer, so not updating ingress")
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger)
return nil
}
@@ -260,22 +309,23 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
return fmt.Errorf("failed to get device ID: %w", err)
}
if tsHost == "" {
logger.Debugf("no Tailscale hostname known yet, waiting for proxy pod to finish auth")
msg := "no Tailscale hostname known yet, waiting for proxy pod to finish auth"
logger.Debug(msg)
// No hostname yet. Wait for the proxy pod to auth.
svc.Status.LoadBalancer.Ingress = nil
if err := a.Status().Update(ctx, svc); err != nil {
return fmt.Errorf("failed to update service status: %w", err)
}
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyPending, msg, a.clock, logger)
return nil
}
logger.Debugf("setting ingress to %q, %s", tsHost, strings.Join(tsIPs, ", "))
logger.Debugf("setting Service LoadBalancer status to %q, %s", tsHost, strings.Join(tsIPs, ", "))
ingress := []corev1.LoadBalancerIngress{
{Hostname: tsHost},
}
clusterIPAddr, err := netip.ParseAddr(svc.Spec.ClusterIP)
if err != nil {
return fmt.Errorf("failed to parse cluster IP: %w", err)
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)
}
for _, ip := range tsIPs {
addr, err := netip.ParseAddr(ip)
@@ -287,9 +337,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
}
}
svc.Status.LoadBalancer.Ingress = ingress
if err := a.Status().Update(ctx, svc); err != nil {
return fmt.Errorf("failed to update service status: %w", err)
}
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger)
return nil
}
@@ -303,6 +351,14 @@ func validateService(svc *corev1.Service) []string {
violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q does not appear to be a valid MagicDNS name", AnnotationTailnetTargetFQDN, fqdn))
}
}
svcName := nameForService(svc)
if err := dnsname.ValidLabel(svcName); err != nil {
if _, ok := svc.Annotations[AnnotationHostname]; ok {
violations = append(violations, fmt.Sprintf("invalid Tailscale hostname specified %q: %s", svcName, err))
} else {
violations = append(violations, fmt.Sprintf("invalid Tailscale hostname %q, use %q annotation to override: %s", svcName, AnnotationHostname, err))
}
}
return violations
}
@@ -334,7 +390,7 @@ func hasExposeAnnotation(svc *corev1.Service) bool {
return svc != nil && svc.Annotations[AnnotationExpose] == "true"
}
// hasTailnetTargetAnnotation returns the value of tailscale.com/tailnet-ip
// tailnetTargetAnnotation returns the value of tailscale.com/tailnet-ip
// annotation or of the deprecated tailscale.com/ts-tailnet-target-ip
// annotation. If neither is set, it returns an empty string. If both are set,
// it returns the value of the new annotation.

View File

@@ -304,10 +304,6 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
func expectedHeadlessService(name string, parentType string) *corev1.Service {
return &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
GenerateName: "ts-test-",
@@ -323,7 +319,8 @@ func expectedHeadlessService(name string, parentType string) *corev1.Service {
Selector: map[string]string{
"app": "1234-UID",
},
ClusterIP: "None",
ClusterIP: "None",
IPFamilyPolicy: ptr.To(corev1.IPFamilyPolicyPreferDualStack),
},
}
}
@@ -331,10 +328,6 @@ func expectedHeadlessService(name string, parentType string) *corev1.Service {
func expectedSecret(t *testing.T, cl client.Client, opts configOpts) *corev1.Secret {
t.Helper()
s := &corev1.Secret{
TypeMeta: metav1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: opts.secretName,
Namespace: "operator-ns",

View File

@@ -448,7 +448,7 @@ func (c *connector) handleTCPFlow(src, dst netip.AddrPort) (handler func(net.Con
// in --ignore-destinations
func (c *connector) ignoreDestination(dstAddrs []netip.Addr) bool {
for _, a := range dstAddrs {
if _, ok := c.ignoreDsts.Get(a); ok {
if _, ok := c.ignoreDsts.Lookup(a); ok {
return true
}
}
@@ -489,7 +489,7 @@ type perPeerState struct {
func (ps *perPeerState) domainForIP(ip netip.Addr) (_ string, ok bool) {
ps.mu.Lock()
defer ps.mu.Unlock()
return ps.addrToDomain.Get(ip)
return ps.addrToDomain.Lookup(ip)
}
// ipForDomain assigns a pair of unique IP addresses for the given domain and
@@ -515,7 +515,7 @@ func (ps *perPeerState) ipForDomain(domain string) ([]netip.Addr, error) {
// domain.
// ps.mu must be held.
func (ps *perPeerState) isIPUsedLocked(ip netip.Addr) bool {
_, ok := ps.addrToDomain.Get(ip)
_, ok := ps.addrToDomain.Lookup(ip)
return ok
}

View File

@@ -46,6 +46,7 @@ var (
backendAddr = flag.String("backend-addr", "", "Address of the Grafana server served over HTTP, in host:port format. Typically localhost:nnnn.")
tailscaleDir = flag.String("state-dir", "./", "Alternate directory to use for Tailscale state storage. If empty, a default is used.")
useHTTPS = flag.Bool("use-https", false, "Serve over HTTPS via your *.ts.net subdomain if enabled in Tailscale admin.")
loginServer = flag.String("login-server", "", "URL to alternative control server. If empty, the default Tailscale control is used.")
)
func main() {
@@ -57,8 +58,9 @@ func main() {
log.Fatal("missing --backend-addr")
}
ts := &tsnet.Server{
Dir: *tailscaleDir,
Hostname: *hostname,
Dir: *tailscaleDir,
Hostname: *hostname,
ControlURL: *loginServer,
}
// TODO(bradfitz,maisem): move this to a method on tsnet.Server probably.

View File

@@ -8,6 +8,7 @@ import (
"log"
"net"
"os"
"strconv"
"tailscale.com/net/stun"
)
@@ -15,12 +16,20 @@ import (
func main() {
log.SetFlags(0)
if len(os.Args) != 2 {
log.Fatalf("usage: %s <hostname>", os.Args[0])
if len(os.Args) < 2 || len(os.Args) > 3 {
log.Fatalf("usage: %s <hostname> [port]", os.Args[0])
}
host := os.Args[1]
port := "3478"
if len(os.Args) == 3 {
port = os.Args[2]
}
_, err := strconv.ParseUint(port, 10, 16)
if err != nil {
log.Fatalf("invalid port: %v", err)
}
uaddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, "3478"))
uaddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, port))
if err != nil {
log.Fatal(err)
}

View File

@@ -2,6 +2,12 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/google/uuid from tailscale.com/util/fastuuid
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
@@ -59,7 +65,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/tsweb
tailscale.com/types/opt from tailscale.com/envknob+
tailscale.com/types/ptr from tailscale.com/tailcfg
tailscale.com/types/ptr from tailscale.com/tailcfg+
tailscale.com/types/structs from tailscale.com/tailcfg+
tailscale.com/types/tkatype from tailscale.com/tailcfg+
tailscale.com/types/views from tailscale.com/net/tsaddr+
@@ -128,6 +134,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/go-json-experiment/json
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/hex from crypto/x509+

View File

@@ -1,142 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"compress/gzip"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
sq "github.com/Masterminds/squirrel"
)
type api struct {
db *db
mux *http.ServeMux
}
func newAPI(db *db) *api {
a := &api{
db: db,
}
mux := http.NewServeMux()
mux.HandleFunc("/query", a.query)
a.mux = mux
return a
}
type apiResult struct {
At int `json:"at"` // time.Time.Unix()
RegionID int `json:"regionID"`
Hostname string `json:"hostname"`
Af int `json:"af"` // 4 or 6
Addr string `json:"addr"`
Source int `json:"source"` // timestampSourceUserspace (0) or timestampSourceKernel (1)
StableConn bool `json:"stableConn"`
DstPort int `json:"dstPort"`
RttNS *int `json:"rttNS"`
}
func getTimeBounds(vals url.Values) (from time.Time, to time.Time, err error) {
lastForm, ok := vals["last"]
if ok && len(lastForm) > 0 {
dur, err := time.ParseDuration(lastForm[0])
if err != nil {
return time.Time{}, time.Time{}, err
}
now := time.Now()
return now.Add(-dur), now, nil
}
fromForm, ok := vals["from"]
if ok && len(fromForm) > 0 {
fromUnixSec, err := strconv.Atoi(fromForm[0])
if err != nil {
return time.Time{}, time.Time{}, err
}
from = time.Unix(int64(fromUnixSec), 0)
toForm, ok := vals["to"]
if ok && len(toForm) > 0 {
toUnixSec, err := strconv.Atoi(toForm[0])
if err != nil {
return time.Time{}, time.Time{}, err
}
to = time.Unix(int64(toUnixSec), 0)
} else {
return time.Time{}, time.Time{}, errors.New("from specified without to")
}
return from, to, nil
}
// no time bounds specified, default to last 1h
now := time.Now()
return now.Add(-time.Hour), now, nil
}
func (a *api) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.mux.ServeHTTP(w, r)
}
func (a *api) query(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
from, to, err := getTimeBounds(r.Form)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
sb := sq.Select("at_unix", "region_id", "hostname", "af", "address", "timestamp_source", "stable_conn", "dst_port", "rtt_ns").From("rtt")
sb = sb.Where(sq.And{
sq.GtOrEq{"at_unix": from.Unix()},
sq.LtOrEq{"at_unix": to.Unix()},
})
query, args, err := sb.ToSql()
if err != nil {
return
}
rows, err := a.db.Query(query, args...)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
results := make([]apiResult, 0)
for rows.Next() {
rtt := 0
result := apiResult{
RttNS: &rtt,
}
err = rows.Scan(&result.At, &result.RegionID, &result.Hostname, &result.Af, &result.Addr, &result.Source, &result.StableConn, &result.DstPort, &result.RttNS)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
results = append(results, result)
}
if rows.Err() != nil {
http.Error(w, rows.Err().Error(), 500)
return
}
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
gz := gzip.NewWriter(w)
defer gz.Close()
w.Header().Set("Content-Encoding", "gzip")
err = json.NewEncoder(gz).Encode(&results)
} else {
err = json.NewEncoder(w).Encode(&results)
}
if err != nil {
http.Error(w, err.Error(), 500)
return
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !(windows && 386)
package main
import (
"database/sql"
_ "modernc.org/sqlite"
)
type db struct {
*sql.DB
}
func newDB(path string) (*db, error) {
d, err := sql.Open("sqlite", *flagOut)
if err != nil {
return nil, err
}
return &db{
DB: d,
}, nil
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"database/sql"
"errors"
)
type db struct {
*sql.DB
}
func newDB(path string) (*db, error) {
return nil, errors.New("unsupported platform")
}

View File

@@ -8,18 +8,42 @@ package main
import (
"errors"
"io"
"net"
"net/netip"
"time"
)
func getConnKernelTimestamp() (io.ReadWriteCloser, error) {
func getUDPConnKernelTimestamp() (io.ReadWriteCloser, error) {
return nil, errors.New("unimplemented")
}
func measureRTTKernel(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Duration, err error) {
func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) {
return 0, errors.New("unimplemented")
}
func supportsKernelTS() bool {
return false
func getProtocolSupportInfo(p protocol) protocolSupportInfo {
switch p {
case protocolSTUN:
return protocolSupportInfo{
kernelTS: false,
userspaceTS: true,
stableConn: true,
}
case protocolHTTPS:
return protocolSupportInfo{
kernelTS: false,
userspaceTS: true,
stableConn: true,
}
case protocolTCP:
return protocolSupportInfo{
kernelTS: true,
userspaceTS: false,
stableConn: true,
}
}
return protocolSupportInfo{}
}
func setSOReuseAddr(fd uintptr) error {
return nil
}

View File

@@ -10,7 +10,8 @@ import (
"errors"
"fmt"
"io"
"net"
"net/netip"
"syscall"
"time"
"github.com/mdlayher/socket"
@@ -24,7 +25,7 @@ const (
unix.SOF_TIMESTAMPING_SOFTWARE // report software timestamps
)
func getConnKernelTimestamp() (io.ReadWriteCloser, error) {
func getUDPConnKernelTimestamp() (io.ReadWriteCloser, error) {
sconn, err := socket.Socket(unix.AF_INET6, unix.SOCK_DGRAM, unix.IPPROTO_UDP, "udp", nil)
if err != nil {
return nil, err
@@ -56,24 +57,23 @@ func parseTimestampFromCmsgs(oob []byte) (time.Time, error) {
return time.Time{}, errors.New("failed to parse timestamp from cmsgs")
}
func measureRTTKernel(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Duration, err error) {
func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname 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)
}
var to unix.Sockaddr
to4 := dst.IP.To4()
if to4 != nil {
if dst.Addr().Is4() {
to = &unix.SockaddrInet4{
Port: dst.Port,
Port: int(dst.Port()),
}
copy(to.(*unix.SockaddrInet4).Addr[:], to4)
copy(to.(*unix.SockaddrInet4).Addr[:], dst.Addr().AsSlice())
} else {
to = &unix.SockaddrInet6{
Port: dst.Port,
Port: int(dst.Port()),
}
copy(to.(*unix.SockaddrInet6).Addr[:], dst.IP)
copy(to.(*unix.SockaddrInet6).Addr[:], dst.Addr().AsSlice())
}
txID := stun.NewTxID()
@@ -138,6 +138,32 @@ func measureRTTKernel(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Durat
}
func supportsKernelTS() bool {
return true
func getProtocolSupportInfo(p protocol) protocolSupportInfo {
switch p {
case protocolSTUN:
return protocolSupportInfo{
kernelTS: true,
userspaceTS: true,
stableConn: true,
}
case protocolHTTPS:
return protocolSupportInfo{
kernelTS: false,
userspaceTS: true,
stableConn: true,
}
case protocolTCP:
return protocolSupportInfo{
kernelTS: true,
userspaceTS: false,
stableConn: true,
}
// TODO(jwhited): add ICMP
}
return protocolSupportInfo{}
}
func setSOReuseAddr(fd uintptr) error {
// we may restart faster than TIME_WAIT can clear
return syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
}

View File

@@ -16,6 +16,7 @@ import (
"net/http"
"os"
"strings"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"software.sslmate.com/src/go-pkcs12"
@@ -34,14 +35,16 @@ var certCmd = &ffcli.Command{
fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.crt if --cert-file and --key-file are both unset")
fs.StringVar(&certArgs.keyFile, "key-file", "", "output key file or \"-\" for stdout; defaults to DOMAIN.key if --cert-file and --key-file are both unset")
fs.BoolVar(&certArgs.serve, "serve-demo", false, "if true, serve on port :443 using the cert as a demo, instead of writing out the files to disk")
fs.DurationVar(&certArgs.minValidity, "min-validity", 0, "ensure the certificate is valid for at least this duration; the output certificate is never expired if this flag is unset or 0, but the lifetime may vary; the maximum allowed min-validity depends on the CA")
return fs
})(),
}
var certArgs struct {
certFile string
keyFile string
serve bool
certFile string
keyFile string
serve bool
minValidity time.Duration
}
func runCert(ctx context.Context, args []string) error {
@@ -102,7 +105,7 @@ func runCert(ctx context.Context, args []string) error {
certArgs.certFile = domain + ".crt"
certArgs.keyFile = domain + ".key"
}
certPEM, keyPEM, err := localClient.CertPair(ctx, domain)
certPEM, keyPEM, err := localClient.CertPairWithValidity(ctx, domain, certArgs.minValidity)
if err != nil {
return err
}

View File

@@ -84,6 +84,13 @@ var localClient = tailscale.LocalClient{
// Run runs the CLI. The args do not include the binary name.
func Run(args []string) (err error) {
if runtime.GOOS == "linux" && os.Getenv("GOKRAZY_FIRST_START") == "1" && distro.Get() == distro.Gokrazy && os.Getppid() == 1 {
// We're running on gokrazy and it's the first start.
// Don't run the tailscale CLI as a service; just exit.
// See https://gokrazy.org/development/process-interface/
os.Exit(0)
}
args = CleanUpArgs(args)
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {

View File

@@ -28,10 +28,12 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/net/http/httpproxy"
"golang.org/x/net/http2"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/control/controlhttp"
"tailscale.com/hostinfo"
"tailscale.com/internal/noiseconn"
"tailscale.com/ipn"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tshttpproxy"
@@ -801,7 +803,10 @@ func runTS2021(ctx context.Context, args []string) error {
log.Printf("Dial(%q, %q) ...", network, address)
c, err := dialer.DialContext(ctx, network, address)
if err != nil {
log.Printf("Dial(%q, %q) = %v", network, address, err)
// skip logging context cancellation errors
if !errors.Is(err, context.Canceled) {
log.Printf("Dial(%q, %q) = %v", network, address, err)
}
} else {
log.Printf("Dial(%q, %q) = %v / %v", network, address, c.LocalAddr(), c.RemoteAddr())
}
@@ -834,6 +839,52 @@ func runTS2021(ctx context.Context, args []string) error {
}
log.Printf("final underlying conn: %v / %v", conn.LocalAddr(), conn.RemoteAddr())
h2Transport, err := http2.ConfigureTransports(&http.Transport{
IdleConnTimeout: time.Second,
})
if err != nil {
return fmt.Errorf("http2.ConfigureTransports: %w", err)
}
// Now, create a Noise conn over the existing conn.
nc, err := noiseconn.New(conn.Conn, h2Transport, 0, nil)
if err != nil {
return fmt.Errorf("noiseconn.New: %w", err)
}
defer nc.Close()
// Reserve a RoundTrip for the whoami request.
ok, _, err := nc.ReserveNewRequest(ctx)
if err != nil {
return fmt.Errorf("ReserveNewRequest: %w", err)
}
if !ok {
return errors.New("ReserveNewRequest failed")
}
// Make a /whoami request to the server to verify that we can actually
// communicate over the newly-established connection.
whoamiURL := "http://" + ts2021Args.host + "/machine/whoami"
req, err = http.NewRequestWithContext(ctx, "GET", whoamiURL, nil)
if err != nil {
return err
}
resp, err := nc.RoundTrip(req)
if err != nil {
return fmt.Errorf("RoundTrip whoami request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Printf("whoami request returned status %v", resp.Status)
} else {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading whoami response: %w", err)
}
log.Printf("whoami response: %q", body)
}
return nil
}

View File

@@ -6,6 +6,7 @@ package cli
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
@@ -66,9 +67,14 @@ func runDriveShare(ctx context.Context, args []string) error {
name, path := args[0], args[1]
err := localClient.DriveShareSet(ctx, &drive.Share{
absolutePath, err := filepath.Abs(path)
if err != nil {
return err
}
err = localClient.DriveShareSet(ctx, &drive.Share{
Name: name,
Path: path,
Path: absolutePath,
})
if err == nil {
fmt.Printf("Sharing %q as %q\n", path, name)

View File

@@ -13,6 +13,7 @@ import (
"strings"
"text/tabwriter"
"github.com/kballard/go-shellquote"
"github.com/peterbourgon/ff/v3/ffcli"
xmaps "golang.org/x/exp/maps"
"tailscale.com/envknob"
@@ -136,6 +137,7 @@ func runExitNodeList(ctx context.Context, args []string) error {
}
fmt.Fprintln(w)
fmt.Fprintln(w)
fmt.Fprintln(w, "# To view the complete list of exit nodes for a country, use `tailscale exit-node list --filter=` followed by the country name.")
fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP.")
if hasAnyExitNodeSuggestions(peers) {
fmt.Fprintln(w, "# To have Tailscale suggest an exit node, use `tailscale exit-node suggest`.")
@@ -154,7 +156,7 @@ func runExitNodeSuggest(ctx context.Context, args []string) error {
fmt.Println("No exit node suggestion is available.")
return nil
}
fmt.Printf("Suggested exit node: %v\nTo accept this suggestion, use `tailscale set --exit-node=%v`.\n", res.Name, res.ID)
fmt.Printf("Suggested exit node: %v\nTo accept this suggestion, use `tailscale set --exit-node=%v`.\n", res.Name, shellquote.Join(res.Name))
return nil
}
@@ -229,7 +231,7 @@ func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string)
for _, ps := range peers {
loc := cmp.Or(ps.Location, noLocation)
if filterBy != "" && loc.Country != filterBy {
if filterBy != "" && !strings.EqualFold(loc.Country, filterBy) {
continue
}
@@ -269,9 +271,14 @@ func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string)
countryAnyPeer = append(countryAnyPeer, city.Peers...)
var reducedCityPeers []*ipnstate.PeerStatus
for i, peer := range city.Peers {
if filterBy != "" {
// If the peers are being filtered, we return all peers to the user.
reducedCityPeers = append(reducedCityPeers, city.Peers...)
break
}
// If the peers are not being filtered, we only return the highest priority peer and any peer that
// is currently the active exit node.
if i == 0 || peer.ExitNode {
// We only return the highest priority peer and any peer that
// is currently the active exit node.
reducedCityPeers = append(reducedCityPeers, peer)
}
}

View File

@@ -219,7 +219,7 @@ func TestFilterFormatAndSortExitNodes(t *testing.T) {
{
Name: "Rainier",
Peers: []*ipnstate.PeerStatus{
ps[2],
ps[2], ps[3],
},
},
},

View File

@@ -1,6 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package internal contains internal code for the ffcomplete package.
package internal
import (

View File

@@ -52,9 +52,15 @@ func runNetcheck(ctx context.Context, args []string) error {
if err != nil {
return err
}
// Ensure that we close the portmapper after running a netcheck; this
// will release any port mappings created.
pm := portmapper.NewClient(logf, netMon, nil, nil, nil)
defer pm.Close()
c := &netcheck.Client{
NetMon: netMon,
PortMapper: portmapper.NewClient(logf, netMon, nil, nil, nil),
PortMapper: pm,
UseDNSCache: false, // always resolve, don't cache
}
if netcheckArgs.verbose {
@@ -78,9 +84,13 @@ func runNetcheck(ctx context.Context, args []string) error {
log.Printf("No DERP map from tailscaled; using default.")
}
if err != nil || noRegions {
hc := &http.Client{Transport: tlsdial.NewTransport()}
hc := &http.Client{
Transport: tlsdial.NewTransport(),
Timeout: 10 * time.Second,
}
dm, err = prodDERPMap(ctx, hc)
if err != nil {
log.Println("Failed to fetch a DERP map, so netcheck cannot continue. Check your Internet connection.")
return err
}
}
@@ -209,6 +219,7 @@ func portMapping(r *netcheck.Report) string {
}
func prodDERPMap(ctx context.Context, httpc *http.Client) (*tailcfg.DERPMap, error) {
log.Printf("attempting to fetch a DERPMap from %s", ipn.DefaultControlURL)
req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil)
if err != nil {
return nil, fmt.Errorf("create prodDERPMap request: %w", err)

View File

@@ -20,6 +20,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
"tailscale.com/tsconst"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
)
@@ -443,15 +444,33 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err
var nlSignCmd = &ffcli.Command{
Name: "sign",
ShortUsage: "tailscale lock sign <node-key> [<rotation-key>] or sign <auth-key>",
ShortUsage: "tailscale lock sign <node-key> [<rotation-key>]\ntailscale lock sign <auth-key>",
ShortHelp: "Signs a node or pre-approved auth key",
LongHelp: `Either:
- signs a node key and transmits the signature to the coordination server, or
- signs a pre-approved auth key, printing it in a form that can be used to bring up nodes under tailnet lock`,
- signs a node key and transmits the signature to the coordination
server, or
- signs a pre-approved auth key, printing it in a form that can be
used to bring up nodes under tailnet lock
If any of the key arguments begin with "file:", the key is retrieved from
the file at the path specified in the argument suffix.`,
Exec: runNetworkLockSign,
}
func runNetworkLockSign(ctx context.Context, args []string) error {
// If any of the arguments start with "file:", replace that argument
// with the contents of the file. We do this early, before the check
// to see if the first argument is an auth key.
for i, arg := range args {
if filename, ok := strings.CutPrefix(arg, "file:"); ok {
b, err := os.ReadFile(filename)
if err != nil {
return err
}
args[i] = strings.TrimSpace(string(b))
}
}
if len(args) > 0 && strings.HasPrefix(args[0], "tskey-auth-") {
return runTskeyWrapCmd(ctx, args)
}
@@ -476,7 +495,7 @@ func runNetworkLockSign(ctx context.Context, args []string) error {
err := localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier()))
// Provide a better help message for when someone clicks through the signing flow
// on the wrong device.
if err != nil && strings.Contains(err.Error(), "this node is not trusted by network lock") {
if err != nil && strings.Contains(err.Error(), tsconst.TailnetLockNotTrustedMsg) {
fmt.Fprintln(Stderr, "Error: Signing is not available on this device because it does not have a trusted tailnet lock key.")
fmt.Fprintln(Stderr)
fmt.Fprintln(Stderr, "Try again on a signing device instead. Tailnet admins can see signing devices on the admin panel.")
@@ -789,7 +808,7 @@ func runNetworkLockRevokeKeys(ctx context.Context, args []string) error {
}
fmt.Printf(`Run the following command on another machine with a trusted tailnet lock key:
%s lock recover-compromised-key --cosign %X
%s lock revoke-keys --cosign %X
`, os.Args[0], aumBytes)
return nil
}
@@ -813,10 +832,10 @@ func runNetworkLockRevokeKeys(ctx context.Context, args []string) error {
fmt.Printf(`Co-signing completed successfully.
To accumulate an additional signature, run the following command on another machine with a trusted tailnet lock key:
%s lock recover-compromised-key --cosign %X
%s lock revoke-keys --cosign %X
Alternatively if you are done with co-signing, complete recovery by running the following command:
%s lock recover-compromised-key --finish %X
%s lock revoke-keys --finish %X
`, os.Args[0], aumBytes, os.Args[0], aumBytes)
}

View File

@@ -74,7 +74,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
@@ -89,7 +89,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -103,7 +103,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -117,7 +117,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -131,7 +131,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -146,7 +146,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -157,10 +157,10 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 9999: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
"/abc": {Proxy: "http://localhost:3001"},
}},
},
},
@@ -171,7 +171,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -182,7 +182,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 8080: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
"foo.test.ts.net:8080": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
@@ -236,7 +236,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -247,10 +247,10 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 9999: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
"/abc": {Proxy: "http://localhost:3001"},
}},
},
},
@@ -261,7 +261,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -272,7 +272,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
@@ -361,7 +361,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
"/foo": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -372,10 +372,10 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
"/foo": {Proxy: "http://localhost:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
"/foo": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -439,7 +439,7 @@ func TestServeDevConfigMutations(t *testing.T) {
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:5432",
TCPForward: "localhost:5432",
TerminateTLS: "foo.test.ts.net",
},
},
@@ -466,7 +466,7 @@ func TestServeDevConfigMutations(t *testing.T) {
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:123",
TCPForward: "localhost:123",
TerminateTLS: "foo.test.ts.net",
},
},
@@ -560,7 +560,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -572,7 +572,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -584,10 +584,10 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "http://127.0.0.1:3001"},
"/bar": {Proxy: "http://localhost:3001"},
}},
},
},
@@ -599,10 +599,10 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "http://127.0.0.1:3001"},
"/bar": {Proxy: "http://localhost:3001"},
}},
},
},
@@ -614,10 +614,10 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "http://127.0.0.1:3001"},
"/bar": {Proxy: "http://localhost:3001"},
}},
},
},
@@ -628,7 +628,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -636,10 +636,10 @@ func TestServeDevConfigMutations(t *testing.T) {
{ // start a tcp forwarder on 8443
command: cmd("serve --bg --tcp=8443 tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "localhost:5432"}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -647,7 +647,7 @@ func TestServeDevConfigMutations(t *testing.T) {
{ // remove primary port http handler
command: cmd("serve off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "localhost:5432"}},
},
},
{ // remove tcp forwarder
@@ -717,7 +717,7 @@ func TestServeDevConfigMutations(t *testing.T) {
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:5432",
TCPForward: "localhost:5432",
TerminateTLS: "foo.test.ts.net",
},
},
@@ -738,7 +738,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -758,7 +758,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{4545: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:4545": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
"/foo": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -769,8 +769,8 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{4545: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:4545": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
"/bar": {Proxy: "http://127.0.0.1:3000"},
"/foo": {Proxy: "http://localhost:3000"},
"/bar": {Proxy: "http://localhost:3000"},
}},
},
},
@@ -800,7 +800,7 @@ func TestServeDevConfigMutations(t *testing.T) {
TCP: map[uint16]*ipn.TCPPortHandler{3000: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:3000": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
"/": {Proxy: "http://localhost:3000"},
}},
},
},

View File

@@ -210,6 +210,9 @@ func runSet(ctx context.Context, args []string) (retErr error) {
}
}
if maskedPrefs.AutoUpdateSet.ApplySet {
if !clientupdate.CanAutoUpdate() {
return errors.New("automatic updates are not supported on this platform")
}
// On macsys, tailscaled will set the Sparkle auto-update setting. It
// does not use clientupdate.
if version.IsMacSysExt() {
@@ -221,10 +224,6 @@ func runSet(ctx context.Context, args []string) (retErr error) {
if err != nil {
return fmt.Errorf("failed to enable automatic updates: %v, %q", err, out)
}
} else {
if !clientupdate.CanAutoUpdate() {
return errors.New("automatic updates are not supported on this platform")
}
}
}
checkPrefs := curPrefs.Clone()

View File

@@ -33,11 +33,13 @@ var updateCmd = &ffcli.Command{
// - Alpine (and other apk-based distros)
// - FreeBSD (and other pkg-based distros)
// - Unraid/QNAP/Synology
// - macOS
if distro.Get() != distro.Arch &&
distro.Get() != distro.Alpine &&
distro.Get() != distro.QNAP &&
distro.Get() != distro.Synology &&
runtime.GOOS != "freebsd" {
runtime.GOOS != "freebsd" &&
runtime.GOOS != "darwin" {
fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`)
fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`)
}

View File

@@ -26,12 +26,14 @@ var whoisCmd = &ffcli.Command{
FlagSet: func() *flag.FlagSet {
fs := newFlagSet("whois")
fs.BoolVar(&whoIsArgs.json, "json", false, "output in JSON format")
fs.StringVar(&whoIsArgs.proto, "proto", "", `protocol; one of "tcp" or "udp"; empty mans both `)
return fs
}(),
}
var whoIsArgs struct {
json bool // output in JSON format
json bool // output in JSON format
proto string // "tcp" or "udp"
}
func runWhoIs(ctx context.Context, args []string) error {
@@ -40,7 +42,7 @@ func runWhoIs(ctx context.Context, args []string) error {
} else if len(args) == 0 {
return errors.New("missing argument, expected one peer")
}
who, err := localClient.WhoIs(ctx, args[0])
who, err := localClient.WhoIsProto(ctx, whoIsArgs.proto, args[0])
if err != nil {
return err
}

View File

@@ -5,10 +5,20 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/coder/websocket from tailscale.com/control/controlhttp+
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/pe+
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
@@ -57,13 +67,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/net/tsaddr+
go4.org/netipx from tailscale.com/net/tsaddr
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+
k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli
nhooyr.io/websocket from tailscale.com/control/controlhttp+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
sigs.k8s.io/yaml from tailscale.com/cmd/tailscale/cli
sigs.k8s.io/yaml/goyaml.v2 from sigs.k8s.io/yaml
software.sslmate.com/src/go-pkcs12 from tailscale.com/cmd/tailscale/cli
@@ -78,7 +84,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli
tailscale.com/cmd/tailscale/cli/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete
tailscale.com/control/controlbase from tailscale.com/control/controlhttp
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
tailscale.com/control/controlknobs from tailscale.com/net/portmapper
tailscale.com/derp from tailscale.com/derp/derphttp
@@ -89,22 +95,24 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli
tailscale.com/hostinfo from tailscale.com/client/web+
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/licenses from tailscale.com/client/web+
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+
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp
tailscale.com/net/flowtrack from tailscale.com/net/packet+
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp+
tailscale.com/net/flowtrack from tailscale.com/net/packet
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli
tailscale.com/net/neterror from tailscale.com/net/netcheck+
tailscale.com/net/netknob from tailscale.com/net/netns
💣 tailscale.com/net/netmon from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/netns from tailscale.com/derp/derphttp+
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp+
tailscale.com/net/netutil from tailscale.com/client/tailscale+
tailscale.com/net/packet from tailscale.com/wgengine/capture+
tailscale.com/net/packet from tailscale.com/wgengine/capture
tailscale.com/net/ping from tailscale.com/net/netcheck
tailscale.com/net/portmapper from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/sockstats from tailscale.com/control/controlhttp+
@@ -120,7 +128,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tempfork/spf13/cobra from tailscale.com/cmd/tailscale/cli/ffcomplete+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/netmon
tailscale.com/tsconst from tailscale.com/net/netmon+
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+
@@ -144,9 +152,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy+
tailscale.com/util/ctxkey from tailscale.com/types/logger
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/groupmember from tailscale.com/client/web
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale+
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
@@ -159,16 +169,18 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/singleflight from tailscale.com/net/dnscache+
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
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/testenv from tailscale.com/cmd/tailscale/cli
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
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
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/client/web+
tailscale.com/version/distro from tailscale.com/client/web+
tailscale.com/wgengine/capture from tailscale.com/cmd/tailscale/cli
tailscale.com/wgengine/filter from tailscale.com/types/netmap
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap
golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from tailscale.com/clientupdate/distsign+
@@ -183,12 +195,13 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
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+
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/exp/maps from tailscale.com/cmd/tailscale/cli+
golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http+
golang.org/x/net/http/httpproxy from net/http+
golang.org/x/net/http2/hpack from net/http
golang.org/x/net/http2 from tailscale.com/cmd/tailscale/cli+
golang.org/x/net/http2/hpack from net/http+
golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/ipv4 from github.com/miekg/dns+
@@ -299,7 +312,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
reflect from archive/tar+
regexp from github.com/coreos/go-iptables/iptables+
regexp/syntax from regexp
runtime/debug from nhooyr.io/websocket/internal/xsync+
runtime/debug from github.com/coder/websocket/internal/xsync+
slices from tailscale.com/client/web+
sort from archive/tar+
strconv from archive/tar+

View File

@@ -17,6 +17,10 @@ func TestDeps(t *testing.T) {
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
"tailscale.com/wgengine/filter": "brings in bart, etc",
"github.com/bits-and-blooms/bitset": "unneeded in CLI",
"github.com/gaissmai/bart": "unneeded in CLI",
"tailscale.com/net/ipset": "unneeded in CLI",
},
}.Check(t)
}

View File

@@ -79,6 +79,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
github.com/bits-and-blooms/bitset from github.com/gaissmai/bart
github.com/coder/websocket from tailscale.com/control/controlhttp+
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com+
@@ -90,11 +94,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 github.com/djherbis/times from tailscale.com/drive/driveimpl
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/gaissmai/bart from tailscale.com/net/tstun+
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+
@@ -211,7 +216,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
gvisor.dev/gvisor/pkg/tcpip/header from gvisor.dev/gvisor/pkg/tcpip/header/parse+
gvisor.dev/gvisor/pkg/tcpip/header/parse from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
gvisor.dev/gvisor/pkg/tcpip/internal/tcp from gvisor.dev/gvisor/pkg/tcpip/stack+
gvisor.dev/gvisor/pkg/tcpip/link/channel from tailscale.com/wgengine/netstack
gvisor.dev/gvisor/pkg/tcpip/network/hash from gvisor.dev/gvisor/pkg/tcpip/network/ipv4
gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
@@ -221,6 +225,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+
💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip/stack/gro from tailscale.com/wgengine/netstack
gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
gvisor.dev/gvisor/pkg/tcpip/transport/icmp from tailscale.com/wgengine/netstack
gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
@@ -231,10 +236,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
gvisor.dev/gvisor/pkg/tcpip/transport/tcpconntrack from gvisor.dev/gvisor/pkg/tcpip/stack
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
nhooyr.io/websocket from tailscale.com/control/controlhttp+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/atomicfile from tailscale.com/ipn+
@@ -245,7 +246,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/clientupdate from tailscale.com/client/web+
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlbase from tailscale.com/control/controlclient+
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlhttp from tailscale.com/control/controlclient
tailscale.com/control/controlknobs from tailscale.com/control/controlclient+
@@ -265,6 +266,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
tailscale.com/hostinfo from tailscale.com/client/web+
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
tailscale.com/ipn from tailscale.com/client/tailscale+
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
@@ -286,6 +288,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+
tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+
tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
tailscale.com/net/connstats from tailscale.com/net/tstun+
tailscale.com/net/dns from tailscale.com/cmd/tailscaled+
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
@@ -295,13 +298,14 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/dnscache from tailscale.com/control/controlclient+
tailscale.com/net/dnsfallback from tailscale.com/cmd/tailscaled+
tailscale.com/net/flowtrack from tailscale.com/net/packet+
tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock+
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
tailscale.com/net/netknob from tailscale.com/logpolicy+
💣 tailscale.com/net/netmon from tailscale.com/cmd/tailscaled+
tailscale.com/net/netns from tailscale.com/cmd/tailscaled+
💣 tailscale.com/net/netns from tailscale.com/cmd/tailscaled+
W 💣 tailscale.com/net/netstat from tailscale.com/portlist
tailscale.com/net/netutil from tailscale.com/client/tailscale+
tailscale.com/net/packet from tailscale.com/net/connstats+
@@ -326,6 +330,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/posture from tailscale.com/ipn/ipnlocal
tailscale.com/proxymap from tailscale.com/tsd+
💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
LD tailscale.com/sessionrecording from tailscale.com/ssh/tailssh
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
tailscale.com/syncs from tailscale.com/cmd/tailscaled+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
@@ -333,7 +338,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/netmon
tailscale.com/tsconst from tailscale.com/net/netmon+
tailscale.com/tsd from tailscale.com/cmd/tailscaled+
tailscale.com/tstime from tailscale.com/control/controlclient+
tailscale.com/tstime/mono from tailscale.com/net/tstun+
@@ -391,6 +396,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
@@ -399,8 +406,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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+
W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/util/zstdframe from tailscale.com/control/controlclient+
tailscale.com/version from tailscale.com/client/web+
tailscale.com/version/distro from tailscale.com/client/web+
@@ -408,6 +416,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/wgengine from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/capture from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap+
💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/netlog from tailscale.com/wgengine
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled

View File

@@ -35,6 +35,7 @@ import (
"tailscale.com/control/controlclient"
"tailscale.com/drive/driveimpl"
"tailscale.com/envknob"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/conffile"
"tailscale.com/ipn/ipnlocal"
@@ -154,9 +155,11 @@ var beCLI func() // non-nil if CLI is linked in
func main() {
envknob.PanicIfAnyEnvCheckedInInit()
envknob.ApplyDiskConfig()
applyIntegrationTestEnvKnob()
defaultVerbosity := envknob.RegisterInt("TS_LOG_VERBOSITY")
printVersion := false
flag.IntVar(&args.verbose, "verbose", 0, "log verbosity level; 0 is default, 1 or higher are increasingly verbose")
flag.IntVar(&args.verbose, "verbose", defaultVerbosity(), "log verbosity level; 0 is default, 1 or higher are increasingly verbose")
flag.BoolVar(&args.cleanUp, "cleanup", false, "clean up system state and exit")
flag.StringVar(&args.debug, "debug", "", "listen address ([ip]:port) of optional debug server")
flag.StringVar(&args.socksAddr, "socks5-server", "", `optional [ip]:port to run a SOCK5 server (e.g. "localhost:1080")`)
@@ -394,7 +397,7 @@ func run() (err error) {
// Always clean up, even if we're going to run the server. This covers cases
// such as when a system was rebooted without shutting down, or tailscaled
// crashed, and would for example restore system DNS configuration.
dns.CleanUp(logf, netMon, args.tunname)
dns.CleanUp(logf, netMon, sys.HealthTracker(), args.tunname)
router.CleanUp(logf, netMon, args.tunname)
// If the cleanUp flag was passed, then exit.
if args.cleanUp {
@@ -698,7 +701,7 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
// configuration being unavailable (from the noop
// manager). More in Issue 4017.
// TODO(bradfitz): add a Synology-specific DNS manager.
conf.DNS, err = dns.NewOSConfigurator(logf, sys.HealthTracker(), "") // empty interface name
conf.DNS, err = dns.NewOSConfigurator(logf, sys.HealthTracker(), sys.ControlKnobs(), "") // empty interface name
if err != nil {
return false, fmt.Errorf("dns.NewOSConfigurator: %w", err)
}
@@ -726,7 +729,7 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
return false, fmt.Errorf("creating router: %w", err)
}
d, err := dns.NewOSConfigurator(logf, sys.HealthTracker(), devName)
d, err := dns.NewOSConfigurator(logf, sys.HealthTracker(), sys.ControlKnobs(), devName)
if err != nil {
dev.Close()
r.Close()
@@ -895,3 +898,24 @@ func dieOnPipeReadErrorOfFD(fd int) {
f.Read(make([]byte, 1))
os.Exit(1)
}
// applyIntegrationTestEnvKnob applies the tailscaled.env=... environment
// variables specified on the Linux kernel command line, if the VM is being
// run in NATLab integration tests.
//
// They're specified as: tailscaled.env=FOO=bar tailscaled.env=BAR=baz
func applyIntegrationTestEnvKnob() {
if runtime.GOOS != "linux" || !hostinfo.IsNATLabGuestVM() {
return
}
cmdLine, _ := os.ReadFile("/proc/cmdline")
for _, s := range strings.Fields(string(cmdLine)) {
suf, ok := strings.CutPrefix(s, "tailscaled.env=")
if !ok {
continue
}
if k, v, ok := strings.Cut(suf, "="); ok {
envknob.Setenv(k, v)
}
}
}

View File

@@ -7,6 +7,7 @@
package main
import (
"bytes"
"context"
crand "crypto/rand"
"crypto/rsa"
@@ -16,6 +17,7 @@ import (
"encoding/binary"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
@@ -25,6 +27,7 @@ import (
"net/netip"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"sync"
@@ -35,6 +38,7 @@ import (
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
@@ -44,13 +48,22 @@ import (
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/util/rands"
"tailscale.com/version"
)
// ctxConn is a key to look up a net.Conn stored in an HTTP request's context.
type ctxConn struct{}
// funnelClientsFile is the file where client IDs and secrets for OIDC clients
// accessing the IDP over Funnel are persisted.
const funnelClientsFile = "oidc-funnel-clients.json"
var (
flagVerbose = flag.Bool("verbose", false, "be verbose")
flagPort = flag.Int("port", 443, "port to listen on")
flagLocalPort = flag.Int("local-port", -1, "allow requests from localhost")
flagUseLocalTailscaled = flag.Bool("use-local-tailscaled", false, "use local tailscaled instead of tsnet")
flagFunnel = flag.Bool("funnel", false, "use Tailscale Funnel to make tsidp available on the public internet")
)
func main() {
@@ -61,9 +74,11 @@ func main() {
}
var (
lc *tailscale.LocalClient
st *ipnstate.Status
err error
lc *tailscale.LocalClient
st *ipnstate.Status
err error
watcherChan chan error
cleanup func()
lns []net.Listener
)
@@ -90,6 +105,18 @@ func main() {
if !anySuccess {
log.Fatalf("failed to listen on any of %v", st.TailscaleIPs)
}
// tailscaled needs to be setting an HTTP header for funneled requests
// that older versions don't provide.
// TODO(naman): is this the correct check?
if *flagFunnel && !version.AtLeast(st.Version, "1.71.0") {
log.Fatalf("Local tailscaled not new enough to support -funnel. Update Tailscale or use tsnet mode.")
}
cleanup, watcherChan, err = serveOnLocalTailscaled(ctx, lc, st, uint16(*flagPort), *flagFunnel)
if err != nil {
log.Fatalf("could not serve on local tailscaled: %v", err)
}
defer cleanup()
} else {
ts := &tsnet.Server{
Hostname: "idp",
@@ -105,7 +132,15 @@ func main() {
if err != nil {
log.Fatalf("getting local client: %v", err)
}
ln, err := ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort))
var ln net.Listener
if *flagFunnel {
if err := ipn.CheckFunnelAccess(uint16(*flagPort), st.Self); err != nil {
log.Fatalf("%v", err)
}
ln, err = ts.ListenFunnel("tcp", fmt.Sprintf(":%d", *flagPort))
} else {
ln, err = ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort))
}
if err != nil {
log.Fatal(err)
}
@@ -113,13 +148,26 @@ func main() {
}
srv := &idpServer{
lc: lc,
lc: lc,
funnel: *flagFunnel,
localTSMode: *flagUseLocalTailscaled,
}
if *flagPort != 443 {
srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort)
} else {
srv.serverURL = fmt.Sprintf("https://%s", strings.TrimSuffix(st.Self.DNSName, "."))
}
if *flagFunnel {
f, err := os.Open(funnelClientsFile)
if err == nil {
srv.funnelClients = make(map[string]*funnelClient)
if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil {
log.Fatalf("could not parse %s: %v", funnelClientsFile, err)
}
} else if !errors.Is(err, os.ErrNotExist) {
log.Fatalf("could not open %s: %v", funnelClientsFile, err)
}
}
log.Printf("Running tsidp at %s ...", srv.serverURL)
@@ -134,35 +182,129 @@ func main() {
}
for _, ln := range lns {
go http.Serve(ln, srv)
server := http.Server{
Handler: srv,
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, ctxConn{}, c)
},
}
go server.Serve(ln)
}
select {}
// need to catch os.Interrupt, otherwise deferred cleanup code doesn't run
exitChan := make(chan os.Signal, 1)
signal.Notify(exitChan, os.Interrupt)
select {
case <-exitChan:
log.Printf("interrupt, exiting")
return
case <-watcherChan:
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
log.Printf("watcher closed, exiting")
return
}
log.Fatalf("watcher error: %v", err)
return
}
}
// serveOnLocalTailscaled starts a serve session using an already-running
// tailscaled instead of starting a fresh tsnet server, making something
// listening on clientDNSName:dstPort accessible over serve/funnel.
func serveOnLocalTailscaled(ctx context.Context, lc *tailscale.LocalClient, st *ipnstate.Status, dstPort uint16, shouldFunnel bool) (cleanup func(), watcherChan chan error, err error) {
// In order to support funneling out in local tailscaled mode, we need
// to add a serve config to forward the listeners we bound above and
// allow those forwarders to be funneled out.
sc, err := lc.GetServeConfig(ctx)
if err != nil {
return nil, nil, fmt.Errorf("could not get serve config: %v", err)
}
if sc == nil {
sc = new(ipn.ServeConfig)
}
// We watch the IPN bus just to get a session ID. The session expires
// when we stop watching the bus, and that auto-deletes the foreground
// serve/funnel configs we are creating below.
watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys)
if err != nil {
return nil, nil, fmt.Errorf("could not set up ipn bus watcher: %v", err)
}
defer func() {
if err != nil {
watcher.Close()
}
}()
n, err := watcher.Next()
if err != nil {
return nil, nil, fmt.Errorf("could not get initial state from ipn bus watcher: %v", err)
}
if n.SessionID == "" {
err = fmt.Errorf("missing sessionID in ipn.Notify")
return nil, nil, err
}
watcherChan = make(chan error)
go func() {
for {
_, err = watcher.Next()
if err != nil {
watcherChan <- err
return
}
}
}()
// Create a foreground serve config that gets cleaned up when tsidp
// exits and the session ID associated with this config is invalidated.
foregroundSc := new(ipn.ServeConfig)
mak.Set(&sc.Foreground, n.SessionID, foregroundSc)
serverURL := strings.TrimSuffix(st.Self.DNSName, ".")
fmt.Printf("setting funnel for %s:%v\n", serverURL, dstPort)
foregroundSc.SetFunnel(serverURL, dstPort, shouldFunnel)
foregroundSc.SetWebHandler(&ipn.HTTPHandler{
Proxy: fmt.Sprintf("https://%s", net.JoinHostPort(serverURL, strconv.Itoa(int(dstPort)))),
}, serverURL, uint16(*flagPort), "/", true)
err = lc.SetServeConfig(ctx, sc)
if err != nil {
return nil, watcherChan, fmt.Errorf("could not set serve config: %v", err)
}
return func() { watcher.Close() }, watcherChan, nil
}
type idpServer struct {
lc *tailscale.LocalClient
loopbackURL string
serverURL string // "https://foo.bar.ts.net"
funnel bool
localTSMode bool
lazyMux lazy.SyncValue[*http.ServeMux]
lazySigningKey lazy.SyncValue[*signingKey]
lazySigner lazy.SyncValue[jose.Signer]
mu sync.Mutex // guards the fields below
code map[string]*authRequest // keyed by random hex
accessToken map[string]*authRequest // keyed by random hex
mu sync.Mutex // guards the fields below
code map[string]*authRequest // keyed by random hex
accessToken map[string]*authRequest // keyed by random hex
funnelClients map[string]*funnelClient // keyed by client ID
}
type authRequest struct {
// localRP is true if the request is from a relying party running on the
// same machine as the idp server. It is mutually exclusive with rpNodeID.
// same machine as the idp server. It is mutually exclusive with rpNodeID
// and funnelRP.
localRP bool
// rpNodeID is the NodeID of the relying party (who requested the auth, such
// as Proxmox or Synology), not the user node who is being authenticated. It
// is mutually exclusive with localRP.
// is mutually exclusive with localRP and funnelRP.
rpNodeID tailcfg.NodeID
// funnelRP is non-nil if the request is from a relying party outside the
// tailnet, via Tailscale Funnel. It is mutually exclusive with rpNodeID
// and localRP.
funnelRP *funnelClient
// clientID is the "client_id" sent in the authorized request.
clientID string
@@ -181,9 +323,12 @@ type authRequest struct {
validTill time.Time
}
func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string, lc *tailscale.LocalClient) error {
// allowRelyingParty validates that a relying party identified either by a
// known remoteAddr or a valid client ID/secret pair is allowed to proceed
// with the authorization flow associated with this authRequest.
func (ar *authRequest) allowRelyingParty(r *http.Request, lc *tailscale.LocalClient) error {
if ar.localRP {
ra, err := netip.ParseAddrPort(remoteAddr)
ra, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return err
}
@@ -192,7 +337,18 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string,
}
return nil
}
who, err := lc.WhoIs(ctx, remoteAddr)
if ar.funnelRP != nil {
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
clientID = r.FormValue("client_id")
clientSecret = r.FormValue("client_secret")
}
if ar.funnelRP.ID != clientID || ar.funnelRP.Secret != clientSecret {
return fmt.Errorf("tsidp: invalid client credentials")
}
return nil
}
who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
return fmt.Errorf("tsidp: error getting WhoIs: %w", err)
}
@@ -203,24 +359,60 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string,
}
func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) {
who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
// This URL is visited by the user who is being authenticated. If they are
// visiting the URL over Funnel, that means they are not part of the
// tailnet that they are trying to be authenticated for.
if isFunnelRequest(r) {
http.Error(w, "tsidp: unauthorized", http.StatusUnauthorized)
return
}
uq := r.URL.Query()
redirectURI := uq.Get("redirect_uri")
if redirectURI == "" {
http.Error(w, "tsidp: must specify redirect_uri", http.StatusBadRequest)
return
}
var remoteAddr string
if s.localTSMode {
// in local tailscaled mode, the local tailscaled is forwarding us
// HTTP requests, so reading r.RemoteAddr will just get us our own
// address.
remoteAddr = r.Header.Get("X-Forwarded-For")
} else {
remoteAddr = r.RemoteAddr
}
who, err := s.lc.WhoIs(r.Context(), remoteAddr)
if err != nil {
log.Printf("Error getting WhoIs: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
uq := r.URL.Query()
code := rands.HexString(32)
ar := &authRequest{
nonce: uq.Get("nonce"),
remoteUser: who,
redirectURI: uq.Get("redirect_uri"),
redirectURI: redirectURI,
clientID: uq.Get("client_id"),
}
if r.URL.Path == "/authorize/localhost" {
if r.URL.Path == "/authorize/funnel" {
s.mu.Lock()
c, ok := s.funnelClients[ar.clientID]
s.mu.Unlock()
if !ok {
http.Error(w, "tsidp: invalid client ID", http.StatusBadRequest)
return
}
if ar.redirectURI != c.RedirectURI {
http.Error(w, "tsidp: redirect_uri mismatch", http.StatusBadRequest)
return
}
ar.funnelRP = c
} else if r.URL.Path == "/authorize/localhost" {
ar.localRP = true
} else {
var ok bool
@@ -237,8 +429,10 @@ func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) {
q := make(url.Values)
q.Set("code", code)
q.Set("state", uq.Get("state"))
u := uq.Get("redirect_uri") + "?" + q.Encode()
if state := uq.Get("state"); state != "" {
q.Set("state", state)
}
u := redirectURI + "?" + q.Encode()
log.Printf("Redirecting to %q", u)
http.Redirect(w, r, u, http.StatusFound)
@@ -251,6 +445,7 @@ func (s *idpServer) newMux() *http.ServeMux {
mux.HandleFunc("/authorize/", s.authorize)
mux.HandleFunc("/userinfo", s.serveUserInfo)
mux.HandleFunc("/token", s.serveToken)
mux.HandleFunc("/clients/", s.serveClients)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
io.WriteString(w, "<html><body><h1>Tailscale OIDC IdP</h1>")
@@ -284,11 +479,6 @@ func (s *idpServer) serveUserInfo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tsidp: invalid token", http.StatusBadRequest)
return
}
if err := ar.allowRelyingParty(r.Context(), r.RemoteAddr, s.lc); err != nil {
log.Printf("Error allowing relying party: %v", err)
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ar.validTill.Before(time.Now()) {
http.Error(w, "tsidp: token expired", http.StatusBadRequest)
@@ -348,7 +538,7 @@ func (s *idpServer) serveToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tsidp: code not found", http.StatusBadRequest)
return
}
if err := ar.allowRelyingParty(r.Context(), r.RemoteAddr, s.lc); err != nil {
if err := ar.allowRelyingParty(r, s.lc); err != nil {
log.Printf("Error allowing relying party: %v", err)
http.Error(w, err.Error(), http.StatusForbidden)
return
@@ -581,7 +771,9 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
}
var authorizeEndpoint string
rpEndpoint := s.serverURL
if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
if isFunnelRequest(r) {
authorizeEndpoint = fmt.Sprintf("%s/authorize/funnel", s.serverURL)
} else if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
authorizeEndpoint = fmt.Sprintf("%s/authorize/%d", s.serverURL, who.Node.ID)
} else if ap.Addr().IsLoopback() {
rpEndpoint = s.loopbackURL
@@ -611,6 +803,148 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
}
}
// funnelClient represents an OIDC client/relying party that is accessing the
// IDP over Funnel.
type funnelClient struct {
ID string `json:"client_id"`
Secret string `json:"client_secret,omitempty"`
Name string `json:"name,omitempty"`
RedirectURI string `json:"redirect_uri"`
}
// /clients is a privileged endpoint that allows the visitor to create new
// Funnel-capable OIDC clients, so it is only accessible over the tailnet.
func (s *idpServer) serveClients(w http.ResponseWriter, r *http.Request) {
if isFunnelRequest(r) {
http.Error(w, "tsidp: not found", http.StatusNotFound)
return
}
path := strings.TrimPrefix(r.URL.Path, "/clients/")
if path == "new" {
s.serveNewClient(w, r)
return
}
if path == "" {
s.serveGetClientsList(w, r)
return
}
s.mu.Lock()
c, ok := s.funnelClients[path]
s.mu.Unlock()
if !ok {
http.Error(w, "tsidp: not found", http.StatusNotFound)
return
}
switch r.Method {
case "DELETE":
s.serveDeleteClient(w, r, path)
case "GET":
json.NewEncoder(w).Encode(&funnelClient{
ID: c.ID,
Name: c.Name,
Secret: "",
RedirectURI: c.RedirectURI,
})
default:
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *idpServer) serveNewClient(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
return
}
redirectURI := r.FormValue("redirect_uri")
if redirectURI == "" {
http.Error(w, "tsidp: must provide redirect_uri", http.StatusBadRequest)
return
}
clientID := rands.HexString(32)
clientSecret := rands.HexString(64)
newClient := funnelClient{
ID: clientID,
Secret: clientSecret,
Name: r.FormValue("name"),
RedirectURI: redirectURI,
}
s.mu.Lock()
defer s.mu.Unlock()
mak.Set(&s.funnelClients, clientID, &newClient)
if err := s.storeFunnelClientsLocked(); err != nil {
log.Printf("could not write funnel clients db: %v", err)
http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError)
// delete the new client to avoid inconsistent state between memory
// and disk
delete(s.funnelClients, clientID)
return
}
json.NewEncoder(w).Encode(newClient)
}
func (s *idpServer) serveGetClientsList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
return
}
s.mu.Lock()
redactedClients := make([]funnelClient, 0, len(s.funnelClients))
for _, c := range s.funnelClients {
redactedClients = append(redactedClients, funnelClient{
ID: c.ID,
Name: c.Name,
Secret: "",
RedirectURI: c.RedirectURI,
})
}
s.mu.Unlock()
json.NewEncoder(w).Encode(redactedClients)
}
func (s *idpServer) serveDeleteClient(w http.ResponseWriter, r *http.Request, clientID string) {
if r.Method != "DELETE" {
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.funnelClients == nil {
http.Error(w, "tsidp: client not found", http.StatusNotFound)
return
}
if _, ok := s.funnelClients[clientID]; !ok {
http.Error(w, "tsidp: client not found", http.StatusNotFound)
return
}
deleted := s.funnelClients[clientID]
delete(s.funnelClients, clientID)
if err := s.storeFunnelClientsLocked(); err != nil {
log.Printf("could not write funnel clients db: %v", err)
http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError)
// restore the deleted value to avoid inconsistent state between memory
// and disk
s.funnelClients[clientID] = deleted
return
}
w.WriteHeader(http.StatusNoContent)
}
// storeFunnelClientsLocked writes the current mapping of OIDC client ID/secret
// pairs for RPs that access the IDP over funnel. s.mu must be held while
// calling this.
func (s *idpServer) storeFunnelClientsLocked() error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(s.funnelClients); err != nil {
return err
}
return os.WriteFile(funnelClientsFile, buf.Bytes(), 0600)
}
const (
minimumRSAKeySize = 2048
)
@@ -700,3 +1034,24 @@ func parseID[T ~int64](input string) (_ T, ok bool) {
}
return T(i), true
}
// isFunnelRequest checks if an HTTP request is coming over Tailscale Funnel.
func isFunnelRequest(r *http.Request) bool {
// If we're funneling through the local tailscaled, it will set this HTTP
// header.
if r.Header.Get("Tailscale-Funnel-Request") != "" {
return true
}
// If the funneled connection is from tsnet, then the net.Conn will be of
// type ipn.FunnelConn.
netConn := r.Context().Value(ctxConn{})
// if the conn is wrapped inside TLS, unwrap it
if tlsConn, ok := netConn.(*tls.Conn); ok {
netConn = tlsConn.NetConn()
}
if _, ok := netConn.(*ipn.FunnelConn); ok {
return true
}
return false
}

128
cmd/tta/fw_linux.go Normal file
View File

@@ -0,0 +1,128 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"encoding/binary"
"github.com/google/nftables"
"github.com/google/nftables/expr"
"tailscale.com/types/ptr"
)
func init() {
addFirewall = addFirewallLinux
}
func addFirewallLinux() error {
c, err := nftables.New()
if err != nil {
return err
}
// Create a new table
table := &nftables.Table{
Family: nftables.TableFamilyIPv4, // TableFamilyINet doesn't work (why?. oh well.)
Name: "filter",
}
c.AddTable(table)
// Create a new chain for incoming traffic
inputChain := &nftables.Chain{
Name: "input",
Table: table,
Type: nftables.ChainTypeFilter,
Hooknum: nftables.ChainHookInput,
Priority: nftables.ChainPriorityFilter,
Policy: ptr.To(nftables.ChainPolicyDrop),
}
c.AddChain(inputChain)
// Allow traffic from the loopback interface
c.AddRule(&nftables.Rule{
Table: table,
Chain: inputChain,
Exprs: []expr.Any{
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte("lo"),
},
&expr.Verdict{
Kind: expr.VerdictAccept,
},
},
})
// Accept established and related connections
c.AddRule(&nftables.Rule{
Table: table,
Chain: inputChain,
Exprs: []expr.Any{
&expr.Ct{
Register: 1,
Key: expr.CtKeySTATE,
},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 4,
Mask: binary.NativeEndian.AppendUint32(nil, 0x06), // CT_STATE_BIT_ESTABLISHED | CT_STATE_BIT_RELATED
Xor: binary.NativeEndian.AppendUint32(nil, 0),
},
&expr.Cmp{
Op: expr.CmpOpNeq,
Register: 1,
Data: binary.NativeEndian.AppendUint32(nil, 0x00),
},
&expr.Verdict{
Kind: expr.VerdictAccept,
},
},
})
// Allow TCP packets in that don't have the SYN bit set, even if they're not
// ESTABLISHED or RELATED. This is because the test suite gets TCP
// connections up & idle (for HTTP) before it conditionally installs these
// firewall rules. But because conntrack wasn't previously active, existing
// TCP flows aren't ESTABLISHED and get dropped. So this rule allows
// previously established TCP connections that predates the firewall rules
// to continue working, as they don't have conntrack state.
c.AddRule(&nftables.Rule{
Table: table,
Chain: inputChain,
Exprs: []expr.Any{
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte{0x06}, // TCP
},
&expr.Payload{ // get TCP flags
DestRegister: 1,
Base: 2,
Offset: 13, // flags
Len: 1,
},
&expr.Bitwise{
SourceRegister: 1,
DestRegister: 1,
Len: 1,
Mask: []byte{2}, // TCP_SYN
Xor: []byte{0},
},
&expr.Cmp{
Op: expr.CmpOpNeq,
Register: 1,
Data: []byte{2}, // TCP_SYN
},
&expr.Verdict{
Kind: expr.VerdictAccept,
},
},
})
return c.Flush()
}

238
cmd/tta/tta.go Normal file
View File

@@ -0,0 +1,238 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The tta server is the Tailscale Test Agent.
//
// It runs on each Tailscale node being integration tested and permits the test
// harness to control the node. It connects out to the test drver (rather than
// accepting any TCP connections inbound, which might be blocked depending on
// the scenario being tested) and then the test driver turns the TCP connection
// around and sends request back.
package main
import (
"context"
"errors"
"flag"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"tailscale.com/client/tailscale"
"tailscale.com/hostinfo"
"tailscale.com/util/must"
"tailscale.com/util/set"
"tailscale.com/version/distro"
)
var (
driverAddr = flag.String("driver", "test-driver.tailscale:8008", "address of the test driver; by default we use the DNS name test-driver.tailscale which is special cased in the emulated network's DNS server")
)
func absify(cmd string) string {
if distro.Get() == distro.Gokrazy && !strings.Contains(cmd, "/") {
return "/user/" + cmd
}
return cmd
}
func serveCmd(w http.ResponseWriter, cmd string, args ...string) {
log.Printf("Got serveCmd for %q %v", cmd, args)
out, err := exec.Command(absify(cmd), args...).CombinedOutput()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if err != nil {
w.Header().Set("Exec-Err", err.Error())
w.WriteHeader(500)
log.Printf("Err on serveCmd for %q %v, %d bytes of output: %v", cmd, args, len(out), err)
} else {
log.Printf("Did serveCmd for %q %v, %d bytes of output", cmd, args, len(out))
}
w.Write(out)
}
type localClientRoundTripper struct {
lc tailscale.LocalClient
}
func (rt *localClientRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.RequestURI = ""
return rt.lc.DoLocalRequest(req)
}
func main() {
if distro.Get() == distro.Gokrazy {
if !hostinfo.IsNATLabGuestVM() {
// "Exiting immediately with status code 0 when the
// GOKRAZY_FIRST_START=1 environment variable is set means “dont
// start the program on boot”"
return
}
}
flag.Parse()
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
}
}
}
logc, err := net.Dial("tcp", "9.9.9.9:124")
if err == nil {
log.SetOutput(logc)
}
log.Printf("Tailscale Test Agent running.")
gokRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://gokrazy")))
gokRP.Transport = &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if network != "tcp" {
return nil, errors.New("unexpected network")
}
if addr != "gokrazy:80" {
return nil, errors.New("unexpected addr")
}
var d net.Dialer
return d.DialContext(ctx, "unix", "/run/gokrazy-http.sock")
},
}
var ttaMux http.ServeMux // agent mux
var serveMux http.ServeMux
serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-TTA-GoKrazy") == "1" {
gokRP.ServeHTTP(w, r)
return
}
ttaMux.ServeHTTP(w, r)
})
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:
}
}
}
conns := make(chan net.Conn, 1)
lcRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://local-tailscaled.sock")))
lcRP.Transport = new(localClientRoundTripper)
ttaMux.HandleFunc("/localapi/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Got localapi request: %v", r.URL)
t0 := time.Now()
lcRP.ServeHTTP(w, r)
log.Printf("Did localapi request in %v: %v", time.Since(t0).Round(time.Millisecond), r.URL)
})
ttaMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "TTA\n")
return
})
ttaMux.HandleFunc("/up", func(w http.ResponseWriter, r *http.Request) {
serveCmd(w, "tailscale", "up", "--login-server=http://control.tailscale")
})
ttaMux.HandleFunc("/fw", addFirewallHandler)
go hs.Serve(chanListener(conns))
// For doing agent operations locally from gokrazy:
// (e.g. with "wget -O - localhost:8123/fw")
go func() {
err := http.ListenAndServe("127.0.0.1:8123", &ttaMux)
if err != nil {
log.Fatalf("ListenAndServe: %v", err)
}
}()
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
}
}
func connect() (net.Conn, error) {
c, err := net.Dial("tcp", *driverAddr)
if err != nil {
return nil, err
}
return c, nil
}
type chanListener <-chan net.Conn
func (cl chanListener) Accept() (net.Conn, error) {
c, ok := <-cl
if !ok {
return nil, errors.New("closed")
}
return c, nil
}
func (cl chanListener) Close() error {
return nil
}
func (cl chanListener) Addr() net.Addr {
return &net.TCPAddr{
IP: net.ParseIP("52.0.0.34"), // TS..DR(iver)
Port: 123,
}
}
func addFirewallHandler(w http.ResponseWriter, r *http.Request) {
if addFirewall == nil {
http.Error(w, "firewall not supported", 500)
return
}
err := addFirewall()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
io.WriteString(w, "OK\n")
}
var addFirewall func() error // set by fw_linux.go

View File

@@ -7,9 +7,13 @@ package tests
import (
"fmt"
"net/netip"
"golang.org/x/exp/constraints"
"tailscale.com/types/ptr"
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded --clone-only-type=OnlyGetClone
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers --clone-only-type=OnlyGetClone
type StructWithoutPtrs struct {
Int int
@@ -25,12 +29,12 @@ type Map struct {
SlicesWithPtrs map[string][]*StructWithPtrs
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
StructWithoutPtrKey map[StructWithoutPtrs]int `json:"-"`
StructWithPtr map[string]StructWithPtrs
// Unsupported views.
SliceIntPtr map[string][]*int
PointerKey map[*string]int `json:"-"`
StructWithPtrKey map[StructWithPtrs]int `json:"-"`
StructWithPtr map[string]StructWithPtrs
}
type StructWithPtrs struct {
@@ -50,12 +54,14 @@ type StructWithSlices struct {
Values []StructWithoutPtrs
ValuePointers []*StructWithoutPtrs
StructPointers []*StructWithPtrs
Structs []StructWithPtrs
Ints []*int
Slice []string
Prefixes []netip.Prefix
Data []byte
// Unsupported views.
Structs []StructWithPtrs
Ints []*int
}
type OnlyGetClone struct {
@@ -66,3 +72,133 @@ type StructWithEmbedded struct {
A *StructWithPtrs
StructWithSlices
}
type GenericIntStruct[T constraints.Integer] struct {
Value T
Pointer *T
Slice []T
Map map[string]T
// Unsupported views.
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}
type BasicType interface {
~bool | constraints.Integer | constraints.Float | constraints.Complex | ~string
}
type GenericNoPtrsStruct[T StructWithoutPtrs | netip.Prefix | BasicType] struct {
Value T
Pointer *T
Slice []T
Map map[string]T
// Unsupported views.
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}
type GenericCloneableStruct[T views.ViewCloner[T, V], V views.StructView[T]] struct {
Value T
Slice []T
Map map[string]T
// Unsupported views.
Pointer *T
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}
// Container is a pre-defined container type, such as a collection, an optional
// value or a generic wrapper.
type Container[T any] struct {
Item T
}
func (c *Container[T]) Clone() *Container[T] {
if c == nil {
return nil
}
if cloner, ok := any(c.Item).(views.Cloner[T]); ok {
return &Container[T]{cloner.Clone()}
}
if !views.ContainsPointers[T]() {
return ptr.To(*c)
}
panic(fmt.Errorf("%T contains pointers, but is not cloneable", c.Item))
}
// ContainerView is a pre-defined readonly view of a Container[T].
type ContainerView[T views.ViewCloner[T, V], V views.StructView[T]] 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.
ж *Container[T]
}
func (cv ContainerView[T, V]) Item() V {
return cv.ж.Item.View()
}
func ContainerViewOf[T views.ViewCloner[T, V], V views.StructView[T]](c *Container[T]) ContainerView[T, V] {
return ContainerView[T, V]{c}
}
// MapContainer is a predefined map-like container type.
// Unlike [Container], it has two type parameters, where the value
// is the second parameter.
type MapContainer[K comparable, V views.Cloner[V]] struct {
Items map[K]V
}
func (c *MapContainer[K, V]) Clone() *MapContainer[K, V] {
if c == nil {
return nil
}
var m map[K]V
if c.Items != nil {
m = make(map[K]V, len(c.Items))
for i := range m {
m[i] = c.Items[i].Clone()
}
}
return &MapContainer[K, V]{m}
}
// MapContainerView is a pre-defined readonly view of a [MapContainer][K, T].
type MapContainerView[K comparable, T views.ViewCloner[T, V], V views.StructView[T]] 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.
ж *MapContainer[K, T]
}
func (cv MapContainerView[K, T, V]) Items() views.MapFn[K, T, V] {
return views.MapFnOf(cv.ж.Items, func(t T) V { return t.View() })
}
func MapContainerViewOf[K comparable, T views.ViewCloner[T, V], V views.StructView[T]](c *MapContainer[K, T]) MapContainerView[K, T, V] {
return MapContainerView[K, T, V]{c}
}
type GenericBasicStruct[T BasicType] struct {
Value T
}
type StructWithContainers struct {
IntContainer Container[int]
CloneableContainer Container[*StructWithPtrs]
BasicGenericContainer Container[GenericBasicStruct[int]]
CloneableGenericContainer Container[*GenericNoPtrsStruct[int]]
CloneableMap MapContainer[int, *StructWithPtrs]
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
}

View File

@@ -9,7 +9,9 @@ import (
"maps"
"net/netip"
"golang.org/x/exp/constraints"
"tailscale.com/types/ptr"
"tailscale.com/types/views"
)
// Clone makes a deep copy of StructWithPtrs.
@@ -71,13 +73,21 @@ func (src *Map) Clone() *Map {
if dst.StructPtrWithPtr != nil {
dst.StructPtrWithPtr = map[string]*StructWithPtrs{}
for k, v := range src.StructPtrWithPtr {
dst.StructPtrWithPtr[k] = v.Clone()
if v == nil {
dst.StructPtrWithPtr[k] = nil
} else {
dst.StructPtrWithPtr[k] = v.Clone()
}
}
}
if dst.StructPtrWithoutPtr != nil {
dst.StructPtrWithoutPtr = map[string]*StructWithoutPtrs{}
for k, v := range src.StructPtrWithoutPtr {
dst.StructPtrWithoutPtr[k] = v.Clone()
if v == nil {
dst.StructPtrWithoutPtr[k] = nil
} else {
dst.StructPtrWithoutPtr[k] = ptr.To(*v)
}
}
}
dst.StructWithoutPtr = maps.Clone(src.StructWithoutPtr)
@@ -94,6 +104,12 @@ func (src *Map) Clone() *Map {
}
}
dst.StructWithoutPtrKey = maps.Clone(src.StructWithoutPtrKey)
if dst.StructWithPtr != nil {
dst.StructWithPtr = map[string]StructWithPtrs{}
for k, v := range src.StructWithPtr {
dst.StructWithPtr[k] = *(v.Clone())
}
}
if dst.SliceIntPtr != nil {
dst.SliceIntPtr = map[string][]*int{}
for k := range src.SliceIntPtr {
@@ -102,12 +118,6 @@ func (src *Map) Clone() *Map {
}
dst.PointerKey = maps.Clone(src.PointerKey)
dst.StructWithPtrKey = maps.Clone(src.StructWithPtrKey)
if dst.StructWithPtr != nil {
dst.StructWithPtr = map[string]StructWithPtrs{}
for k, v := range src.StructWithPtr {
dst.StructWithPtr[k] = *(v.Clone())
}
}
return dst
}
@@ -121,10 +131,10 @@ var _MapCloneNeedsRegeneration = Map(struct {
SlicesWithPtrs map[string][]*StructWithPtrs
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
StructWithoutPtrKey map[StructWithoutPtrs]int
StructWithPtr map[string]StructWithPtrs
SliceIntPtr map[string][]*int
PointerKey map[*string]int
StructWithPtrKey map[StructWithPtrs]int
StructWithPtr map[string]StructWithPtrs
}{})
// Clone makes a deep copy of StructWithSlices.
@@ -139,15 +149,26 @@ func (src *StructWithSlices) Clone() *StructWithSlices {
if src.ValuePointers != nil {
dst.ValuePointers = make([]*StructWithoutPtrs, len(src.ValuePointers))
for i := range dst.ValuePointers {
dst.ValuePointers[i] = src.ValuePointers[i].Clone()
if src.ValuePointers[i] == nil {
dst.ValuePointers[i] = nil
} else {
dst.ValuePointers[i] = ptr.To(*src.ValuePointers[i])
}
}
}
if src.StructPointers != nil {
dst.StructPointers = make([]*StructWithPtrs, len(src.StructPointers))
for i := range dst.StructPointers {
dst.StructPointers[i] = src.StructPointers[i].Clone()
if src.StructPointers[i] == nil {
dst.StructPointers[i] = nil
} else {
dst.StructPointers[i] = src.StructPointers[i].Clone()
}
}
}
dst.Slice = append(src.Slice[:0:0], src.Slice...)
dst.Prefixes = append(src.Prefixes[:0:0], src.Prefixes...)
dst.Data = append(src.Data[:0:0], src.Data...)
if src.Structs != nil {
dst.Structs = make([]StructWithPtrs, len(src.Structs))
for i := range dst.Structs {
@@ -164,9 +185,6 @@ func (src *StructWithSlices) Clone() *StructWithSlices {
}
}
}
dst.Slice = append(src.Slice[:0:0], src.Slice...)
dst.Prefixes = append(src.Prefixes[:0:0], src.Prefixes...)
dst.Data = append(src.Data[:0:0], src.Data...)
return dst
}
@@ -175,11 +193,11 @@ var _StructWithSlicesCloneNeedsRegeneration = StructWithSlices(struct {
Values []StructWithoutPtrs
ValuePointers []*StructWithoutPtrs
StructPointers []*StructWithPtrs
Structs []StructWithPtrs
Ints []*int
Slice []string
Prefixes []netip.Prefix
Data []byte
Structs []StructWithPtrs
Ints []*int
}{})
// Clone makes a deep copy of OnlyGetClone.
@@ -216,3 +234,210 @@ var _StructWithEmbeddedCloneNeedsRegeneration = StructWithEmbedded(struct {
A *StructWithPtrs
StructWithSlices
}{})
// Clone makes a deep copy of GenericIntStruct.
// The result aliases no memory with the original.
func (src *GenericIntStruct[T]) Clone() *GenericIntStruct[T] {
if src == nil {
return nil
}
dst := new(GenericIntStruct[T])
*dst = *src
if dst.Pointer != nil {
dst.Pointer = ptr.To(*src.Pointer)
}
dst.Slice = append(src.Slice[:0:0], src.Slice...)
dst.Map = maps.Clone(src.Map)
if src.PtrSlice != nil {
dst.PtrSlice = make([]*T, len(src.PtrSlice))
for i := range dst.PtrSlice {
if src.PtrSlice[i] == nil {
dst.PtrSlice[i] = nil
} else {
dst.PtrSlice[i] = ptr.To(*src.PtrSlice[i])
}
}
}
dst.PtrKeyMap = maps.Clone(src.PtrKeyMap)
if dst.PtrValueMap != nil {
dst.PtrValueMap = map[string]*T{}
for k, v := range src.PtrValueMap {
if v == nil {
dst.PtrValueMap[k] = nil
} else {
dst.PtrValueMap[k] = ptr.To(*v)
}
}
}
if dst.SliceMap != nil {
dst.SliceMap = map[string][]T{}
for k := range src.SliceMap {
dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...)
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
func _GenericIntStructCloneNeedsRegeneration[T constraints.Integer](GenericIntStruct[T]) {
_GenericIntStructCloneNeedsRegeneration(struct {
Value T
Pointer *T
Slice []T
Map map[string]T
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}{})
}
// Clone makes a deep copy of GenericNoPtrsStruct.
// The result aliases no memory with the original.
func (src *GenericNoPtrsStruct[T]) Clone() *GenericNoPtrsStruct[T] {
if src == nil {
return nil
}
dst := new(GenericNoPtrsStruct[T])
*dst = *src
if dst.Pointer != nil {
dst.Pointer = ptr.To(*src.Pointer)
}
dst.Slice = append(src.Slice[:0:0], src.Slice...)
dst.Map = maps.Clone(src.Map)
if src.PtrSlice != nil {
dst.PtrSlice = make([]*T, len(src.PtrSlice))
for i := range dst.PtrSlice {
if src.PtrSlice[i] == nil {
dst.PtrSlice[i] = nil
} else {
dst.PtrSlice[i] = ptr.To(*src.PtrSlice[i])
}
}
}
dst.PtrKeyMap = maps.Clone(src.PtrKeyMap)
if dst.PtrValueMap != nil {
dst.PtrValueMap = map[string]*T{}
for k, v := range src.PtrValueMap {
if v == nil {
dst.PtrValueMap[k] = nil
} else {
dst.PtrValueMap[k] = ptr.To(*v)
}
}
}
if dst.SliceMap != nil {
dst.SliceMap = map[string][]T{}
for k := range src.SliceMap {
dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...)
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
func _GenericNoPtrsStructCloneNeedsRegeneration[T StructWithoutPtrs | netip.Prefix | BasicType](GenericNoPtrsStruct[T]) {
_GenericNoPtrsStructCloneNeedsRegeneration(struct {
Value T
Pointer *T
Slice []T
Map map[string]T
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}{})
}
// Clone makes a deep copy of GenericCloneableStruct.
// The result aliases no memory with the original.
func (src *GenericCloneableStruct[T, V]) Clone() *GenericCloneableStruct[T, V] {
if src == nil {
return nil
}
dst := new(GenericCloneableStruct[T, V])
*dst = *src
dst.Value = src.Value.Clone()
if src.Slice != nil {
dst.Slice = make([]T, len(src.Slice))
for i := range dst.Slice {
dst.Slice[i] = src.Slice[i].Clone()
}
}
if dst.Map != nil {
dst.Map = map[string]T{}
for k, v := range src.Map {
dst.Map[k] = v.Clone()
}
}
if dst.Pointer != nil {
dst.Pointer = ptr.To((*src.Pointer).Clone())
}
if src.PtrSlice != nil {
dst.PtrSlice = make([]*T, len(src.PtrSlice))
for i := range dst.PtrSlice {
if src.PtrSlice[i] == nil {
dst.PtrSlice[i] = nil
} else {
dst.PtrSlice[i] = ptr.To((*src.PtrSlice[i]).Clone())
}
}
}
dst.PtrKeyMap = maps.Clone(src.PtrKeyMap)
if dst.PtrValueMap != nil {
dst.PtrValueMap = map[string]*T{}
for k, v := range src.PtrValueMap {
if v == nil {
dst.PtrValueMap[k] = nil
} else {
dst.PtrValueMap[k] = ptr.To((*v).Clone())
}
}
}
if dst.SliceMap != nil {
dst.SliceMap = map[string][]T{}
for k := range src.SliceMap {
dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...)
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
func _GenericCloneableStructCloneNeedsRegeneration[T views.ViewCloner[T, V], V views.StructView[T]](GenericCloneableStruct[T, V]) {
_GenericCloneableStructCloneNeedsRegeneration(struct {
Value T
Slice []T
Map map[string]T
Pointer *T
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}{})
}
// Clone makes a deep copy of StructWithContainers.
// The result aliases no memory with the original.
func (src *StructWithContainers) Clone() *StructWithContainers {
if src == nil {
return nil
}
dst := new(StructWithContainers)
*dst = *src
dst.CloneableContainer = *src.CloneableContainer.Clone()
dst.CloneableGenericContainer = *src.CloneableGenericContainer.Clone()
dst.CloneableMap = *src.CloneableMap.Clone()
dst.CloneableGenericMap = *src.CloneableGenericMap.Clone()
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithContainersCloneNeedsRegeneration = StructWithContainers(struct {
IntContainer Container[int]
CloneableContainer Container[*StructWithPtrs]
BasicGenericContainer Container[GenericBasicStruct[int]]
CloneableGenericContainer Container[*GenericNoPtrsStruct[int]]
CloneableMap MapContainer[int, *StructWithPtrs]
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
}{})

View File

@@ -10,10 +10,11 @@ import (
"errors"
"net/netip"
"golang.org/x/exp/constraints"
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers
// View returns a readonly view of StructWithPtrs.
func (p *StructWithPtrs) View() StructWithPtrsView {
@@ -188,11 +189,7 @@ func (v *MapView) UnmarshalJSON(b []byte) error {
func (v MapView) Int() views.Map[string, int] { return views.MapOf(v.ж.Int) }
func (v MapView) SliceInt() views.MapFn[string, []int, views.Slice[int]] {
return views.MapFnOf(v.ж.SliceInt, func(t []int) views.Slice[int] {
return views.SliceOf(t)
})
}
func (v MapView) SliceInt() views.MapSlice[string, int] { return views.MapSliceOf(v.ж.SliceInt) }
func (v MapView) StructPtrWithPtr() views.MapFn[string, *StructWithPtrs, StructWithPtrsView] {
return views.MapFnOf(v.ж.StructPtrWithPtr, func(t *StructWithPtrs) StructWithPtrsView {
@@ -225,15 +222,15 @@ func (v MapView) SlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrs, v
func (v MapView) StructWithoutPtrKey() views.Map[StructWithoutPtrs, int] {
return views.MapOf(v.ж.StructWithoutPtrKey)
}
func (v MapView) SliceIntPtr() map[string][]*int { panic("unsupported") }
func (v MapView) PointerKey() map[*string]int { panic("unsupported") }
func (v MapView) StructWithPtrKey() map[StructWithPtrs]int { panic("unsupported") }
func (v MapView) StructWithPtr() views.MapFn[string, StructWithPtrs, StructWithPtrsView] {
return views.MapFnOf(v.ж.StructWithPtr, func(t StructWithPtrs) StructWithPtrsView {
return t.View()
})
}
func (v MapView) SliceIntPtr() map[string][]*int { panic("unsupported") }
func (v MapView) PointerKey() map[*string]int { panic("unsupported") }
func (v MapView) StructWithPtrKey() map[StructWithPtrs]int { panic("unsupported") }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _MapViewNeedsRegeneration = Map(struct {
@@ -245,10 +242,10 @@ var _MapViewNeedsRegeneration = Map(struct {
SlicesWithPtrs map[string][]*StructWithPtrs
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
StructWithoutPtrKey map[StructWithoutPtrs]int
StructWithPtr map[string]StructWithPtrs
SliceIntPtr map[string][]*int
PointerKey map[*string]int
StructWithPtrKey map[StructWithPtrs]int
StructWithPtr map[string]StructWithPtrs
}{})
// View returns a readonly view of StructWithSlices.
@@ -305,24 +302,24 @@ func (v StructWithSlicesView) ValuePointers() views.SliceView[*StructWithoutPtrs
func (v StructWithSlicesView) StructPointers() views.SliceView[*StructWithPtrs, StructWithPtrsView] {
return views.SliceOfViews[*StructWithPtrs, StructWithPtrsView](v.ж.StructPointers)
}
func (v StructWithSlicesView) Structs() StructWithPtrs { panic("unsupported") }
func (v StructWithSlicesView) Ints() *int { panic("unsupported") }
func (v StructWithSlicesView) Slice() views.Slice[string] { return views.SliceOf(v.ж.Slice) }
func (v StructWithSlicesView) Prefixes() views.Slice[netip.Prefix] {
return views.SliceOf(v.ж.Prefixes)
}
func (v StructWithSlicesView) Data() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Data) }
func (v StructWithSlicesView) Structs() StructWithPtrs { panic("unsupported") }
func (v StructWithSlicesView) Ints() *int { panic("unsupported") }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithSlicesViewNeedsRegeneration = StructWithSlices(struct {
Values []StructWithoutPtrs
ValuePointers []*StructWithoutPtrs
StructPointers []*StructWithPtrs
Structs []StructWithPtrs
Ints []*int
Slice []string
Prefixes []netip.Prefix
Data []byte
Structs []StructWithPtrs
Ints []*int
}{})
// View returns a readonly view of StructWithEmbedded.
@@ -380,3 +377,302 @@ var _StructWithEmbeddedViewNeedsRegeneration = StructWithEmbedded(struct {
A *StructWithPtrs
StructWithSlices
}{})
// View returns a readonly view of GenericIntStruct.
func (p *GenericIntStruct[T]) View() GenericIntStructView[T] {
return GenericIntStructView[T]{ж: p}
}
// GenericIntStructView[T] provides a read-only view over GenericIntStruct[T].
//
// Its methods should only be called if `Valid()` returns true.
type GenericIntStructView[T constraints.Integer] 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.
ж *GenericIntStruct[T]
}
// Valid reports whether underlying value is non-nil.
func (v GenericIntStructView[T]) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v GenericIntStructView[T]) AsStruct() *GenericIntStruct[T] {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v GenericIntStructView[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *GenericIntStructView[T]) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x GenericIntStruct[T]
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v GenericIntStructView[T]) Value() T { return v.ж.Value }
func (v GenericIntStructView[T]) Pointer() *T {
if v.ж.Pointer == nil {
return nil
}
x := *v.ж.Pointer
return &x
}
func (v GenericIntStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) }
func (v GenericIntStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) }
func (v GenericIntStructView[T]) PtrSlice() *T { panic("unsupported") }
func (v GenericIntStructView[T]) PtrKeyMap() map[*T]string { panic("unsupported") }
func (v GenericIntStructView[T]) PtrValueMap() map[string]*T { panic("unsupported") }
func (v GenericIntStructView[T]) SliceMap() map[string][]T { panic("unsupported") }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
func _GenericIntStructViewNeedsRegeneration[T constraints.Integer](GenericIntStruct[T]) {
_GenericIntStructViewNeedsRegeneration(struct {
Value T
Pointer *T
Slice []T
Map map[string]T
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}{})
}
// View returns a readonly view of GenericNoPtrsStruct.
func (p *GenericNoPtrsStruct[T]) View() GenericNoPtrsStructView[T] {
return GenericNoPtrsStructView[T]{ж: p}
}
// GenericNoPtrsStructView[T] provides a read-only view over GenericNoPtrsStruct[T].
//
// Its methods should only be called if `Valid()` returns true.
type GenericNoPtrsStructView[T StructWithoutPtrs | netip.Prefix | BasicType] 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.
ж *GenericNoPtrsStruct[T]
}
// Valid reports whether underlying value is non-nil.
func (v GenericNoPtrsStructView[T]) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v GenericNoPtrsStructView[T]) AsStruct() *GenericNoPtrsStruct[T] {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v GenericNoPtrsStructView[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *GenericNoPtrsStructView[T]) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x GenericNoPtrsStruct[T]
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v GenericNoPtrsStructView[T]) Value() T { return v.ж.Value }
func (v GenericNoPtrsStructView[T]) Pointer() *T {
if v.ж.Pointer == nil {
return nil
}
x := *v.ж.Pointer
return &x
}
func (v GenericNoPtrsStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) }
func (v GenericNoPtrsStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) }
func (v GenericNoPtrsStructView[T]) PtrSlice() *T { panic("unsupported") }
func (v GenericNoPtrsStructView[T]) PtrKeyMap() map[*T]string { panic("unsupported") }
func (v GenericNoPtrsStructView[T]) PtrValueMap() map[string]*T { panic("unsupported") }
func (v GenericNoPtrsStructView[T]) SliceMap() map[string][]T { panic("unsupported") }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
func _GenericNoPtrsStructViewNeedsRegeneration[T StructWithoutPtrs | netip.Prefix | BasicType](GenericNoPtrsStruct[T]) {
_GenericNoPtrsStructViewNeedsRegeneration(struct {
Value T
Pointer *T
Slice []T
Map map[string]T
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}{})
}
// View returns a readonly view of GenericCloneableStruct.
func (p *GenericCloneableStruct[T, V]) View() GenericCloneableStructView[T, V] {
return GenericCloneableStructView[T, V]{ж: p}
}
// GenericCloneableStructView[T, V] provides a read-only view over GenericCloneableStruct[T, V].
//
// Its methods should only be called if `Valid()` returns true.
type GenericCloneableStructView[T views.ViewCloner[T, V], V views.StructView[T]] 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.
ж *GenericCloneableStruct[T, V]
}
// Valid reports whether underlying value is non-nil.
func (v GenericCloneableStructView[T, V]) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v GenericCloneableStructView[T, V]) AsStruct() *GenericCloneableStruct[T, V] {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v GenericCloneableStructView[T, V]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *GenericCloneableStructView[T, V]) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x GenericCloneableStruct[T, V]
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v GenericCloneableStructView[T, V]) Value() V { return v.ж.Value.View() }
func (v GenericCloneableStructView[T, V]) Slice() views.SliceView[T, V] {
return views.SliceOfViews[T, V](v.ж.Slice)
}
func (v GenericCloneableStructView[T, V]) Map() views.MapFn[string, T, V] {
return views.MapFnOf(v.ж.Map, func(t T) V {
return t.View()
})
}
func (v GenericCloneableStructView[T, V]) Pointer() map[string]T { panic("unsupported") }
func (v GenericCloneableStructView[T, V]) PtrSlice() *T { panic("unsupported") }
func (v GenericCloneableStructView[T, V]) PtrKeyMap() map[*T]string { panic("unsupported") }
func (v GenericCloneableStructView[T, V]) PtrValueMap() map[string]*T { panic("unsupported") }
func (v GenericCloneableStructView[T, V]) SliceMap() map[string][]T { panic("unsupported") }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
func _GenericCloneableStructViewNeedsRegeneration[T views.ViewCloner[T, V], V views.StructView[T]](GenericCloneableStruct[T, V]) {
_GenericCloneableStructViewNeedsRegeneration(struct {
Value T
Slice []T
Map map[string]T
Pointer *T
PtrSlice []*T
PtrKeyMap map[*T]string `json:"-"`
PtrValueMap map[string]*T
SliceMap map[string][]T
}{})
}
// View returns a readonly view of StructWithContainers.
func (p *StructWithContainers) View() StructWithContainersView {
return StructWithContainersView{ж: p}
}
// StructWithContainersView provides a read-only view over StructWithContainers.
//
// Its methods should only be called if `Valid()` returns true.
type StructWithContainersView 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.
ж *StructWithContainers
}
// Valid reports whether underlying value is non-nil.
func (v StructWithContainersView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v StructWithContainersView) AsStruct() *StructWithContainers {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v StructWithContainersView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *StructWithContainersView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x StructWithContainers
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v StructWithContainersView) IntContainer() Container[int] { return v.ж.IntContainer }
func (v StructWithContainersView) CloneableContainer() ContainerView[*StructWithPtrs, StructWithPtrsView] {
return ContainerViewOf(&v.ж.CloneableContainer)
}
func (v StructWithContainersView) BasicGenericContainer() Container[GenericBasicStruct[int]] {
return v.ж.BasicGenericContainer
}
func (v StructWithContainersView) CloneableGenericContainer() ContainerView[*GenericNoPtrsStruct[int], GenericNoPtrsStructView[int]] {
return ContainerViewOf(&v.ж.CloneableGenericContainer)
}
func (v StructWithContainersView) CloneableMap() MapContainerView[int, *StructWithPtrs, StructWithPtrsView] {
return MapContainerViewOf(&v.ж.CloneableMap)
}
func (v StructWithContainersView) CloneableGenericMap() MapContainerView[int, *GenericNoPtrsStruct[int], GenericNoPtrsStructView[int]] {
return MapContainerViewOf(&v.ж.CloneableGenericMap)
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _StructWithContainersViewNeedsRegeneration = StructWithContainers(struct {
IntContainer Container[int]
CloneableContainer Container[*StructWithPtrs]
BasicGenericContainer Container[GenericBasicStruct[int]]
CloneableGenericContainer Container[*GenericNoPtrsStruct[int]]
CloneableMap MapContainer[int, *StructWithPtrs]
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
}{})

View File

@@ -13,50 +13,52 @@ import (
"html/template"
"log"
"os"
"slices"
"strings"
"tailscale.com/util/codegen"
"tailscale.com/util/must"
)
const viewTemplateStr = `{{define "common"}}
// View returns a readonly view of {{.StructName}}.
func (p *{{.StructName}}) View() {{.ViewName}} {
return {{.ViewName}}{ж: p}
func (p *{{.StructName}}{{.TypeParamNames}}) View() {{.ViewName}}{{.TypeParamNames}} {
return {{.ViewName}}{{.TypeParamNames}}{ж: p}
}
// {{.ViewName}} provides a read-only view over {{.StructName}}.
// {{.ViewName}}{{.TypeParamNames}} provides a read-only view over {{.StructName}}{{.TypeParamNames}}.
//
// Its methods should only be called if ` + "`Valid()`" + ` returns true.
type {{.ViewName}} struct {
type {{.ViewName}}{{.TypeParams}} 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.
ж *{{.StructName}}
ж *{{.StructName}}{{.TypeParamNames}}
}
// Valid reports whether underlying value is non-nil.
func (v {{.ViewName}}) Valid() bool { return v.ж != nil }
func (v {{.ViewName}}{{.TypeParamNames}}) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v {{.ViewName}}) AsStruct() *{{.StructName}}{
func (v {{.ViewName}}{{.TypeParamNames}}) AsStruct() *{{.StructName}}{{.TypeParamNames}}{
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v {{.ViewName}}) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v {{.ViewName}}{{.TypeParamNames}}) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *{{.ViewName}}) UnmarshalJSON(b []byte) error {
func (v *{{.ViewName}}{{.TypeParamNames}}) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x {{.StructName}}
var x {{.StructName}}{{.TypeParamNames}}
if err := json.Unmarshal(b, &x); err != nil {
return err
}
@@ -65,17 +67,19 @@ func (v *{{.ViewName}}) UnmarshalJSON(b []byte) error {
}
{{end}}
{{define "valueField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} { return v.ж.{{.FieldName}} }
{{define "valueField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} { return v.ж.{{.FieldName}} }
{{end}}
{{define "byteSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.ByteSlice[{{.FieldType}}] { return views.ByteSliceOf(v.ж.{{.FieldName}}) }
{{define "byteSliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.ByteSlice[{{.FieldType}}] { return views.ByteSliceOf(v.ж.{{.FieldName}}) }
{{end}}
{{define "sliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.Slice[{{.FieldType}}] { return views.SliceOf(v.ж.{{.FieldName}}) }
{{define "sliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.Slice[{{.FieldType}}] { return views.SliceOf(v.ж.{{.FieldName}}) }
{{end}}
{{define "viewSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.SliceView[{{.FieldType}},{{.FieldViewName}}] { return views.SliceOfViews[{{.FieldType}},{{.FieldViewName}}](v.ж.{{.FieldName}}) }
{{define "viewSliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.SliceView[{{.FieldType}},{{.FieldViewName}}] { return views.SliceOfViews[{{.FieldType}},{{.FieldViewName}}](v.ж.{{.FieldName}}) }
{{end}}
{{define "viewField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}}View { return v.ж.{{.FieldName}}.View() }
{{define "viewField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldViewName}} { return v.ж.{{.FieldName}}.View() }
{{end}}
{{define "valuePointerField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} {
{{define "makeViewField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldViewName}} { return {{.MakeViewFnName}}(&v.ж.{{.FieldName}}) }
{{end}}
{{define "valuePointerField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} {
if v.ж.{{.FieldName}} == nil {
return nil
}
@@ -85,18 +89,21 @@ func (v *{{.ViewName}}) UnmarshalJSON(b []byte) error {
{{end}}
{{define "mapField"}}
func(v {{.ViewName}}) {{.FieldName}}() views.Map[{{.MapKeyType}},{{.MapValueType}}] { return views.MapOf(v.ж.{{.FieldName}})}
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.Map[{{.MapKeyType}},{{.MapValueType}}] { return views.MapOf(v.ж.{{.FieldName}})}
{{end}}
{{define "mapFnField"}}
func(v {{.ViewName}}) {{.FieldName}}() views.MapFn[{{.MapKeyType}},{{.MapValueType}},{{.MapValueView}}] { return views.MapFnOf(v.ж.{{.FieldName}}, func (t {{.MapValueType}}) {{.MapValueView}} {
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapFn[{{.MapKeyType}},{{.MapValueType}},{{.MapValueView}}] { return views.MapFnOf(v.ж.{{.FieldName}}, func (t {{.MapValueType}}) {{.MapValueView}} {
return {{.MapFn}}
})}
{{end}}
{{define "unsupportedField"}}func(v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} {panic("unsupported")}
{{define "mapSliceField"}}
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapSlice[{{.MapKeyType}},{{.MapValueType}}] { return views.MapSliceOf(v.ж.{{.FieldName}}) }
{{end}}
{{define "stringFunc"}}func(v {{.ViewName}}) String() string { return v.ж.String() }
{{define "unsupportedField"}}func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} {panic("unsupported")}
{{end}}
{{define "equalFunc"}}func(v {{.ViewName}}) Equal(v2 {{.ViewName}}) bool { return v.ж.Equal(v2.ж) }
{{define "stringFunc"}}func(v {{.ViewName}}{{.TypeParamNames}}) String() string { return v.ж.String() }
{{end}}
{{define "equalFunc"}}func(v {{.ViewName}}{{.TypeParamNames}}) Equal(v2 {{.ViewName}}{{.TypeParamNames}}) bool { return v.ж.Equal(v2.ж) }
{{end}}
`
@@ -128,8 +135,11 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
it.Import("errors")
args := struct {
StructName string
ViewName string
StructName string
ViewName string
TypeParams string // e.g. [T constraints.Integer]
TypeParamNames string // e.g. [T]
FieldName string
FieldType string
FieldViewName string
@@ -138,11 +148,17 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
MapValueType string
MapValueView string
MapFn string
// MakeViewFnName is the name of the function that accepts a value and returns a readonly view of it.
MakeViewFnName string
}{
StructName: typ.Obj().Name(),
ViewName: typ.Obj().Name() + "View",
ViewName: typ.Origin().Obj().Name() + "View",
}
typeParams := typ.Origin().TypeParams()
args.TypeParams, args.TypeParamNames = codegen.FormatTypeParams(typeParams, it)
writeTemplate := func(name string) {
if err := viewTemplate.ExecuteTemplate(buf, name, args); err != nil {
log.Fatal(err)
@@ -179,19 +195,35 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
it.Import("tailscale.com/types/views")
shallow, deep, base := requiresCloning(elem)
if deep {
if _, isPtr := elem.(*types.Pointer); isPtr {
args.FieldViewName = it.QualifiedName(base) + "View"
writeTemplate("viewSliceField")
} else {
writeTemplate("unsupportedField")
switch elem.Underlying().(type) {
case *types.Pointer:
if _, isIface := base.Underlying().(*types.Interface); !isIface {
args.FieldViewName = appendNameSuffix(it.QualifiedName(base), "View")
writeTemplate("viewSliceField")
} else {
writeTemplate("unsupportedField")
}
continue
case *types.Interface:
if viewType := viewTypeForValueType(elem); viewType != nil {
args.FieldViewName = it.QualifiedName(viewType)
writeTemplate("viewSliceField")
continue
}
}
writeTemplate("unsupportedField")
continue
} else if shallow {
if _, isBasic := base.(*types.Basic); isBasic {
switch base.Underlying().(type) {
case *types.Basic, *types.Interface:
writeTemplate("unsupportedField")
} else {
args.FieldViewName = it.QualifiedName(base) + "View"
writeTemplate("viewSliceField")
default:
if _, isIface := base.Underlying().(*types.Interface); !isIface {
args.FieldViewName = appendNameSuffix(it.QualifiedName(base), "View")
writeTemplate("viewSliceField")
} else {
writeTemplate("unsupportedField")
}
}
continue
}
@@ -202,7 +234,18 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
strucT := underlying
args.FieldType = it.QualifiedName(fieldType)
if codegen.ContainsPointers(strucT) {
writeTemplate("viewField")
if viewType := viewTypeForValueType(fieldType); viewType != nil {
args.FieldViewName = it.QualifiedName(viewType)
writeTemplate("viewField")
continue
}
if viewType, makeViewFn := viewTypeForContainerType(fieldType); viewType != nil {
args.FieldViewName = it.QualifiedName(viewType)
args.MakeViewFnName = it.PackagePrefix(makeViewFn.Pkg()) + makeViewFn.Name()
writeTemplate("makeViewField")
continue
}
writeTemplate("unsupportedField")
continue
}
writeTemplate("valueField")
@@ -226,7 +269,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
args.MapFn = "t.View()"
template = "mapFnField"
args.MapValueType = it.QualifiedName(mElem)
args.MapValueView = args.MapValueType + "View"
args.MapValueView = appendNameSuffix(args.MapValueType, "View")
} else {
template = "mapField"
args.MapValueType = it.QualifiedName(mElem)
@@ -241,21 +284,25 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
case *types.Basic, *types.Named:
sElem := it.QualifiedName(sElem)
args.MapValueView = fmt.Sprintf("views.Slice[%v]", sElem)
args.MapValueType = "[]" + sElem
args.MapFn = "views.SliceOf(t)"
template = "mapFnField"
args.MapValueType = sElem
template = "mapSliceField"
case *types.Pointer:
ptr := x
pElem := ptr.Elem()
switch pElem.(type) {
case *types.Struct, *types.Named:
ptrType := it.QualifiedName(ptr)
viewType := it.QualifiedName(pElem) + "View"
args.MapFn = fmt.Sprintf("views.SliceOfViews[%v,%v](t)", ptrType, viewType)
args.MapValueView = fmt.Sprintf("views.SliceView[%v,%v]", ptrType, viewType)
args.MapValueType = "[]" + ptrType
template = "mapFnField"
default:
template = "unsupportedField"
if _, isIface := pElem.Underlying().(*types.Interface); !isIface {
switch pElem.(type) {
case *types.Struct, *types.Named:
ptrType := it.QualifiedName(ptr)
viewType := appendNameSuffix(it.QualifiedName(pElem), "View")
args.MapFn = fmt.Sprintf("views.SliceOfViews[%v,%v](t)", ptrType, viewType)
args.MapValueView = fmt.Sprintf("views.SliceView[%v,%v]", ptrType, viewType)
args.MapValueType = "[]" + ptrType
template = "mapFnField"
default:
template = "unsupportedField"
}
} else {
template = "unsupportedField"
}
default:
@@ -264,13 +311,29 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
case *types.Pointer:
ptr := u
pElem := ptr.Elem()
switch pElem.(type) {
case *types.Struct, *types.Named:
args.MapValueType = it.QualifiedName(ptr)
args.MapValueView = it.QualifiedName(pElem) + "View"
if _, isIface := pElem.Underlying().(*types.Interface); !isIface {
switch pElem.(type) {
case *types.Struct, *types.Named:
args.MapValueType = it.QualifiedName(ptr)
args.MapValueView = appendNameSuffix(it.QualifiedName(pElem), "View")
args.MapFn = "t.View()"
template = "mapFnField"
default:
template = "unsupportedField"
}
} else {
template = "unsupportedField"
}
case *types.Interface, *types.TypeParam:
if viewType := viewTypeForValueType(u); viewType != nil {
args.MapValueType = it.QualifiedName(u)
args.MapValueView = it.QualifiedName(viewType)
args.MapFn = "t.View()"
template = "mapFnField"
default:
} else if !codegen.ContainsPointers(u) {
args.MapValueType = it.QualifiedName(mElem)
template = "mapField"
} else {
template = "unsupportedField"
}
default:
@@ -281,14 +344,28 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
case *types.Pointer:
ptr := underlying
_, deep, base := requiresCloning(ptr)
if deep {
args.FieldType = it.QualifiedName(base)
writeTemplate("viewField")
if _, isIface := base.Underlying().(*types.Interface); !isIface {
args.FieldType = it.QualifiedName(base)
args.FieldViewName = appendNameSuffix(args.FieldType, "View")
writeTemplate("viewField")
} else {
writeTemplate("unsupportedField")
}
} else {
args.FieldType = it.QualifiedName(ptr)
writeTemplate("valuePointerField")
}
continue
case *types.Interface:
// If fieldType is an interface with a "View() {ViewType}" method, it can be used to clone the field.
// This includes scenarios where fieldType is a constrained type parameter.
if viewType := viewTypeForValueType(underlying); viewType != nil {
args.FieldViewName = it.QualifiedName(viewType)
writeTemplate("viewField")
continue
}
}
writeTemplate("unsupportedField")
}
@@ -316,7 +393,132 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
}
}
fmt.Fprintf(buf, "\n")
buf.Write(codegen.AssertStructUnchanged(t, args.StructName, "View", it))
buf.Write(codegen.AssertStructUnchanged(t, args.StructName, typeParams, "View", it))
}
func appendNameSuffix(name, suffix string) string {
if idx := strings.IndexRune(name, '['); idx != -1 {
// Insert suffix after the type name, but before type parameters.
return name[:idx] + suffix + name[idx:]
}
return name + suffix
}
func viewTypeForValueType(typ types.Type) types.Type {
if ptr, ok := typ.(*types.Pointer); ok {
return viewTypeForValueType(ptr.Elem())
}
viewMethod := codegen.LookupMethod(typ, "View")
if viewMethod == nil {
return nil
}
sig, ok := viewMethod.Type().(*types.Signature)
if !ok || sig.Results().Len() != 1 {
return nil
}
return sig.Results().At(0).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)
if !ok || containerType.TypeArgs().Len() == 0 {
return nil, nil
}
// Look up the view type for the container type.
// It must include an additional type parameter specifying the element's view type.
// For example, Container[T] => ContainerView[T, V].
containerViewTypeName := containerType.Obj().Name() + "View"
containerViewTypeObj, ok := containerType.Obj().Pkg().Scope().Lookup(containerViewTypeName).(*types.TypeName)
if !ok {
return nil, nil
}
containerViewGenericType, ok := containerViewTypeObj.Type().(*types.Named)
if !ok || containerViewGenericType.TypeParams().Len() != containerType.TypeArgs().Len()+1 {
return nil, nil
}
// Create a list of type arguments for instantiating the container view type.
// Include all type arguments specified for the container type...
containerViewTypeArgs := make([]types.Type, containerViewGenericType.TypeParams().Len())
for i := range containerType.TypeArgs().Len() {
containerViewTypeArgs[i] = containerType.TypeArgs().At(i)
}
// ...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)
if !ok {
return nil, nil
}
// ...then infer the view type from it.
var elemViewType *types.Named
elemTypeName := elemType.Obj().Name()
elemViewTypeBaseName := elemType.Obj().Name() + "View"
if elemViewTypeName, ok := elemType.Obj().Pkg().Scope().Lookup(elemViewTypeBaseName).(*types.TypeName); ok {
// The elem's view type is already defined in the same package as the elem type.
elemViewType = elemViewTypeName.Type().(*types.Named)
} else if slices.Contains(typeNames, elemTypeName) {
// The elem's view type has not been generated yet, but we can define
// and use a blank type with the expected view type name.
elemViewTypeName = types.NewTypeName(0, elemType.Obj().Pkg(), elemViewTypeBaseName, nil)
elemViewType = types.NewNamed(elemViewTypeName, types.NewStruct(nil, nil), nil)
if elemTypeParams := elemType.TypeParams(); elemTypeParams != nil {
elemViewType.SetTypeParams(collectTypeParams(elemTypeParams))
}
} else {
// The elem view type does not exist and won't be generated.
return nil, nil
}
// 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)
}
// And finally set the elemViewType as the last type argument.
containerViewTypeArgs[len(containerViewTypeArgs)-1] = elemViewType
// Instantiate the container view type with the specified type arguments.
containerViewType := must.Get(types.Instantiate(nil, containerViewGenericType, containerViewTypeArgs, false))
// Look up a function to create a view of a container.
// It should be in the same package as the container type, named {ViewType}Of,
// and have a signature like {ViewType}Of(c *Container[T]) ContainerView[T, V].
makeContainerView, ok := containerType.Obj().Pkg().Scope().Lookup(containerViewTypeName + "Of").(*types.Func)
if !ok {
return nil, nil
}
return containerViewType.(*types.Named), makeContainerView
}
func baseType(typ types.Type) types.Type {
if ptr, ok := typ.(*types.Pointer); ok {
return ptr.Elem()
}
return typ
}
func collectTypes(list *types.TypeList) []types.Type {
// TODO(nickkhyl): use slices.Collect in Go 1.23?
if list.Len() == 0 {
return nil
}
res := make([]types.Type, list.Len())
for i := range res {
res[i] = list.At(i)
}
return res
}
func collectTypeParams(list *types.TypeParamList) []*types.TypeParam {
if list.Len() == 0 {
return nil
}
res := make([]*types.TypeParam, list.Len())
for i := range res {
p := list.At(i)
res[i] = types.NewTypeParam(p.Obj(), p.Constraint())
}
return res
}
var (
@@ -325,6 +527,8 @@ var (
flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func")
flagCloneOnlyTypes = flag.String("clone-only-type", "", "comma-separated list of types (a subset of --type) that should only generate a go:generate clone line and not actual views")
typeNames []string
)
func main() {
@@ -335,7 +539,7 @@ func main() {
flag.Usage()
os.Exit(2)
}
typeNames := strings.Split(*flagTypes, ",")
typeNames = strings.Split(*flagTypes, ",")
var flagArgs []string
flagArgs = append(flagArgs, fmt.Sprintf("-clonefunc=%v", *flagCloneFunc))
@@ -379,7 +583,11 @@ func main() {
}
genView(buf, it, typ, pkg.Types)
}
out := pkg.Name + "_view.go"
out := pkg.Name + "_view"
if *flagBuildTags == "test" {
out += "_test"
}
out += ".go"
if err := codegen.WritePackageFile("tailscale/cmd/viewer", pkg, out, it, buf); err != nil {
log.Fatal(err)
}

21
cmd/vnet/run-krazy.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
echo "Type 'C-a c' to enter monitor; q to quit."
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" \
-drive id=blk0,file=$HOME/src/tailscale.com/gokrazy/natlabapp.img,format=raw \
-device virtio-blk-device,drive=blk0 \
-device virtio-rng-device \
-netdev stream,id=net0,addr.type=unix,addr.path=/tmp/qemu.sock \
-device virtio-serial-device \
-device virtio-net-device,netdev=net0,mac=52:cc:cc:cc:cc:01 \
-chardev stdio,id=virtiocon0,mux=on \
-device virtconsole,chardev=virtiocon0 \
-mon chardev=virtiocon0,mode=readline \
-audio none

118
cmd/vnet/vnet-main.go Normal file
View File

@@ -0,0 +1,118 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The vnet binary runs a virtual network stack in userspace for qemu instances
// to connect to and simulate various network conditions.
package main
import (
"context"
"flag"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"time"
"tailscale.com/tstest/natlab/vnet"
"tailscale.com/types/logger"
"tailscale.com/util/must"
)
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")
)
func main() {
flag.Parse()
if _, err := os.Stat(*listen); err == nil {
os.Remove(*listen)
}
var srv net.Listener
var err error
var conn *net.UnixConn
if *dgram {
addr, err := net.ResolveUnixAddr("unixgram", *listen)
if err != nil {
log.Fatalf("ResolveUnixAddr: %v", err)
}
conn, err = net.ListenUnixgram("unixgram", addr)
if err != nil {
log.Fatalf("ListenUnixgram: %v", err)
}
defer conn.Close()
} else {
srv, err = net.Listen("unix", *listen)
}
if err != nil {
log.Fatal(err)
}
var c vnet.Config
node1 := c.AddNode(c.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.NAT(*nat)))
c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", vnet.NAT(*nat2)))
if *portmap {
node1.Network().AddService(vnet.NATPMP)
}
s, err := vnet.New(&c)
if err != nil {
log.Fatalf("newServer: %v", err)
}
if err := s.PopulateDERPMapIPs(); err != nil {
log.Printf("warning: ignoring failure to populate DERP map: %v", err)
}
s.WriteStartingBanner(os.Stdout)
nc := s.NodeAgentClient(node1)
go func() {
rp := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://gokrazy")))
d := rp.Director
rp.Director = func(r *http.Request) {
d(r)
r.Header.Set("X-TTA-GoKrazy", "1")
}
rp.Transport = nc.HTTPClient.Transport
http.ListenAndServe(":8080", rp)
}()
go func() {
getStatus := func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
st, err := nc.Status(ctx)
if err != nil {
log.Printf("NodeStatus: %v", err)
return
}
log.Printf("NodeStatus: %v", logger.AsJSON(st))
}
for {
time.Sleep(5 * time.Second)
//continue
getStatus()
}
}()
if conn != nil {
s.ServeUnixConn(conn, vnet.ProtocolUnixDGRAM)
return
}
for {
c, err := srv.Accept()
if err != nil {
log.Printf("Accept: %v", err)
continue
}
go s.ServeUnixConn(c.(*net.UnixConn), vnet.ProtocolQEMU)
}
}

108
cmd/xdpderper/xdpderper.go Normal file
View File

@@ -0,0 +1,108 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Command xdpderper runs the XDP STUN server.
package main
import (
"flag"
"io"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"github.com/prometheus/client_golang/prometheus"
"tailscale.com/derp/xdp"
"tailscale.com/net/netutil"
"tailscale.com/tsweb"
)
var (
flagDevice = flag.String("device", "", "target device name (default: autodetect)")
flagPort = flag.Int("dst-port", 0, "destination UDP port to serve")
flagVerbose = flag.Bool("verbose", false, "verbose output including verifier errors")
flagMode = flag.String("mode", "xdp", "XDP mode; valid modes: [xdp, xdpgeneric, xdpdrv, xdpoffload]")
flagHTTP = flag.String("http", ":8230", "HTTP listen address")
)
func main() {
flag.Parse()
var attachFlags xdp.XDPAttachFlags
switch strings.ToLower(*flagMode) {
case "xdp":
attachFlags = 0
case "xdpgeneric":
attachFlags = xdp.XDPGenericMode
case "xdpdrv":
attachFlags = xdp.XDPDriverMode
case "xdpoffload":
attachFlags = xdp.XDPOffloadMode
default:
log.Fatal("invalid mode")
}
deviceName := *flagDevice
if deviceName == "" {
var err error
deviceName, _, err = netutil.DefaultInterfacePortable()
if err != nil || deviceName == "" {
log.Fatalf("failed to detect default route interface: %v", err)
}
}
log.Printf("binding to device: %s", deviceName)
server, err := xdp.NewSTUNServer(&xdp.STUNServerConfig{
DeviceName: deviceName,
DstPort: *flagPort,
AttachFlags: attachFlags,
FullVerifierErr: *flagVerbose,
})
if err != nil {
log.Fatalf("failed to init XDP STUN server: %v", err)
}
defer server.Close()
err = prometheus.Register(server)
if err != nil {
log.Fatalf("failed to register XDP STUN server as a prometheus collector: %v", err)
}
log.Println("XDP STUN server started")
mux := http.NewServeMux()
debug := tsweb.Debugger(mux)
debug.KVFunc("Drop STUN", func() any {
return server.GetDropSTUN()
})
debug.Handle("drop-stun-on", "Drop STUN packets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := server.SetDropSTUN(true)
if err != nil {
http.Error(w, err.Error(), 500)
} else {
io.WriteString(w, "STUN packets are now being dropped.")
}
}))
debug.Handle("drop-stun-off", "Handle STUN packets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := server.SetDropSTUN(false)
if err != nil {
http.Error(w, err.Error(), 500)
} else {
io.WriteString(w, "STUN packets are now being handled.")
}
}))
errCh := make(chan error, 1)
go func() {
err := http.ListenAndServe(*flagHTTP, mux)
errCh <- err
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
log.Printf("HTTP serve err: %v", err)
case sig := <-sigCh:
log.Printf("received signal: %s", sig)
}
}

View File

@@ -7,14 +7,13 @@ import (
"bufio"
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"net/netip"
@@ -25,6 +24,7 @@ import (
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"go4.org/mem"
@@ -62,6 +62,7 @@ import (
// Direct is the client that connects to a tailcontrol server for a node.
type Direct struct {
httpc *http.Client // HTTP client used to talk to tailcontrol
interceptedDial *atomic.Bool // if non-nil, pointer to bool whether ScreenTime intercepted our dial
dialer *tsdial.Dialer
dnsCache *dnscache.Resolver
controlKnobs *controlknobs.Knobs // always non-nil
@@ -258,23 +259,28 @@ func NewDirect(opts Options) (*Direct, error) {
// etc set).
httpc = http.DefaultClient
}
var interceptedDial *atomic.Bool
if httpc == nil {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
tr.TLSClientConfig = tlsdial.Config(serverURL.Hostname(), opts.HealthTracker, tr.TLSClientConfig)
tr.DialContext = dnscache.Dialer(opts.Dialer.SystemDial, dnsCache)
tr.DialTLSContext = dnscache.TLSDialer(opts.Dialer.SystemDial, dnsCache, tr.TLSClientConfig)
var dialFunc dialFunc
dialFunc, interceptedDial = makeScreenTimeDetectingDialFunc(opts.Dialer.SystemDial)
tr.DialContext = dnscache.Dialer(dialFunc, dnsCache)
tr.DialTLSContext = dnscache.TLSDialer(dialFunc, dnsCache, tr.TLSClientConfig)
tr.ForceAttemptHTTP2 = true
// Disable implicit gzip compression; the various
// handlers (register, map, set-dns, etc) do their own
// zstd compression per naclbox.
tr.DisableCompression = true
httpc = &http.Client{Transport: tr}
}
c := &Direct{
httpc: httpc,
interceptedDial: interceptedDial,
controlKnobs: opts.ControlKnobs,
getMachinePrivKey: opts.GetMachinePrivateKey,
serverURL: opts.ServerURL,
@@ -327,6 +333,9 @@ func (c *Direct) Close() error {
}
}
c.noiseClient = nil
if tr, ok := c.httpc.Transport.(*http.Transport); ok {
tr.CloseIdleConnections()
}
return nil
}
@@ -464,6 +473,16 @@ func (c *Direct) hostInfoLocked() *tailcfg.Hostinfo {
return hi
}
var macOSScreenTime = health.Register(&health.Warnable{
Code: "macos-screen-time-controlclient",
Severity: health.SeverityHigh,
Title: "Tailscale blocked by Screen Time",
Text: func(args health.Args) string {
return "macOS Screen Time seems to be blocking Tailscale. Try disabling Screen Time in System Settings > Screen Time > Content & Privacy > Access to Web Content."
},
ImpactsConnectivity: true,
})
func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, nks tkatype.MarshaledSignature, err error) {
if c.panicOnUse {
panic("tainted client")
@@ -473,7 +492,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
tryingNewKey := c.tryingNewKey
serverKey := c.serverLegacyKey
serverNoiseKey := c.serverNoiseKey
authKey, isWrapped, wrappedSig, wrappedKey := decodeWrappedAuthkey(c.authKey, c.logf)
authKey, isWrapped, wrappedSig, wrappedKey := tka.DecodeWrappedAuthkey(c.authKey, c.logf)
hi := c.hostInfoLocked()
backendLogID := hi.BackendLogID
expired := !c.expiry.IsZero() && c.expiry.Before(c.clock.Now())
@@ -505,6 +524,11 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
c.logf("doLogin(regen=%v, hasUrl=%v)", regen, opt.URL != "")
if serverKey.IsZero() {
keys, err := loadServerPubKeys(ctx, c.httpc, c.serverURL)
if err != nil && c.interceptedDial != nil && c.interceptedDial.Load() {
c.health.SetUnhealthy(macOSScreenTime, nil)
} else {
c.health.SetHealthy(macOSScreenTime)
}
if err != nil {
return regen, opt.URL, nil, err
}
@@ -565,18 +589,10 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
// We were given a wrapped pre-auth key, which means that in addition
// to being a regular pre-auth key there was a suffix with information to
// generate a tailnet-lock signature.
nk, err := tryingNewKey.Public().MarshalBinary()
nodeKeySignature, err = tka.SignByCredential(wrappedKey, wrappedSig, tryingNewKey.Public())
if err != nil {
return false, "", nil, fmt.Errorf("marshalling node-key: %w", err)
return false, "", nil, err
}
sig := &tka.NodeKeySignature{
SigKind: tka.SigRotation,
Pubkey: nk,
Nested: wrappedSig,
}
sigHash := sig.SigHash()
sig.Signature = ed25519.Sign(wrappedKey, sigHash[:])
nodeKeySignature = sig.Serialize()
}
if backendLogID == "" {
@@ -1581,9 +1597,9 @@ func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailc
}
// ReportHealthChange reports to the control plane a change to this node's
// health.
func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
if sys == health.SysOverall {
// health. w must be non-nil. us can be nil to indicate a healthy state for w.
func (c *Direct) ReportHealthChange(w *health.Warnable, us *health.UnhealthyState) {
if w == health.NetworkStatusWarnable || w == health.IPNStateWarnable || w == health.LoginStateWarnable {
// We don't report these. These include things like the network is down
// (in which case we can't report anyway) or the user wanted things
// stopped, as opposed to the more unexpected failure types in the other
@@ -1602,12 +1618,13 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
if c.panicOnUse {
panic("tainted client")
}
// TODO(angott): at some point, update `Subsys` in the request to be `Warnable`
req := &tailcfg.HealthChangeRequest{
Subsys: string(sys),
Subsys: string(w.Code),
NodeKey: nodeKey,
}
if sysErr != nil {
req.Error = sysErr.Error()
if us != nil {
req.Error = us.Text
}
// Best effort, no logging:
@@ -1620,49 +1637,44 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
res.Body.Close()
}
// decodeWrappedAuthkey separates wrapping information from an authkey, if any.
// In all cases the authkey is returned, sans wrapping information if any.
//
// If the authkey is wrapped, isWrapped returns true, along with the wrapping signature
// and private key.
func decodeWrappedAuthkey(key string, logf logger.Logf) (authKey string, isWrapped bool, sig *tka.NodeKeySignature, priv ed25519.PrivateKey) {
authKey, suffix, found := strings.Cut(key, "--TL")
if !found {
return key, false, nil, nil
}
sigBytes, privBytes, found := strings.Cut(suffix, "-")
if !found {
logf("decoding wrapped auth-key: did not find delimiter")
return key, false, nil, nil
}
rawSig, err := base64.RawStdEncoding.DecodeString(sigBytes)
if err != nil {
logf("decoding wrapped auth-key: signature decode: %v", err)
return key, false, nil, nil
}
rawPriv, err := base64.RawStdEncoding.DecodeString(privBytes)
if err != nil {
logf("decoding wrapped auth-key: priv decode: %v", err)
return key, false, nil, nil
}
sig = new(tka.NodeKeySignature)
if err := sig.Unserialize([]byte(rawSig)); err != nil {
logf("decoding wrapped auth-key: signature: %v", err)
return key, false, nil, nil
}
priv = ed25519.PrivateKey(rawPriv)
return authKey, true, sig, priv
}
func addLBHeader(req *http.Request, nodeKey key.NodePublic) {
if !nodeKey.IsZero() {
req.Header.Add(tailcfg.LBHeader, nodeKey.String())
}
}
type dialFunc = func(ctx context.Context, network, addr string) (net.Conn, error)
// makeScreenTimeDetectingDialFunc returns dialFunc, optionally wrapped (on
// Apple systems) with a func that sets the returned atomic.Bool for whether
// Screen Time seemed to intercept the connection.
//
// The returned *atomic.Bool is nil on non-Apple systems.
func makeScreenTimeDetectingDialFunc(dial dialFunc) (dialFunc, *atomic.Bool) {
switch runtime.GOOS {
case "darwin", "ios":
// Continue below.
default:
return dial, nil
}
ab := new(atomic.Bool)
return func(ctx context.Context, network, addr string) (net.Conn, error) {
c, err := dial(ctx, network, addr)
if err != nil {
return nil, err
}
ab.Store(isTCPLoopback(c.LocalAddr()) && isTCPLoopback(c.RemoteAddr()))
return c, nil
}, ab
}
func isTCPLoopback(a net.Addr) bool {
if ta, ok := a.(*net.TCPAddr); ok {
return ta.IP.IsLoopback()
}
return false
}
var (
metricMapRequestsActive = clientmetric.NewGauge("controlclient_map_requests_active")

View File

@@ -4,7 +4,6 @@
package controlclient
import (
"crypto/ed25519"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -147,42 +146,3 @@ func TestTsmpPing(t *testing.T) {
t.Fatal(err)
}
}
func TestDecodeWrappedAuthkey(t *testing.T) {
k, isWrapped, sig, priv := decodeWrappedAuthkey("tskey-32mjsdkdsffds9o87dsfkjlh", nil)
if want := "tskey-32mjsdkdsffds9o87dsfkjlh"; k != want {
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).key = %q, want %q", k, want)
}
if isWrapped {
t.Error("decodeWrappedAuthkey(<unwrapped-key>).isWrapped = true, want false")
}
if sig != nil {
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).sig = %v, want nil", sig)
}
if priv != nil {
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).priv = %v, want nil", priv)
}
k, isWrapped, sig, priv = decodeWrappedAuthkey("tskey-auth-k7UagY1CNTRL-ZZZZZ--TLpAEDA1ggnXuw4/fWnNWUwcoOjLemhOvml1juMl5lhLmY5sBUsj8EWEAfL2gdeD9g8VDw5tgcxCiHGlEb67BgU2DlFzZApi4LheLJraA+pYjTGChVhpZz1iyiBPD+U2qxDQAbM3+WFY0EBlggxmVqG53Hu0Rg+KmHJFMlUhfgzo+AQP6+Kk9GzvJJOs4-k36RdoSFqaoARfQo0UncHAV0t3YTqrkD5r/z2jTrE43GZWobnce7RGD4qYckUyVSF+DOj4BA/r4qT0bO8kk6zg", nil)
if want := "tskey-auth-k7UagY1CNTRL-ZZZZZ"; k != want {
t.Errorf("decodeWrappedAuthkey(<wrapped-key>).key = %q, want %q", k, want)
}
if !isWrapped {
t.Error("decodeWrappedAuthkey(<wrapped-key>).isWrapped = false, want true")
}
if sig == nil {
t.Fatal("decodeWrappedAuthkey(<wrapped-key>).sig = nil, want non-nil signature")
}
sigHash := sig.SigHash()
if !ed25519.Verify(sig.KeyID, sigHash[:], sig.Signature) {
t.Error("signature failed to verify")
}
// Make sure the private is correct by using it.
someSig := ed25519.Sign(priv, []byte{1, 2, 3, 4})
if !ed25519.Verify(sig.WrappingPubkey, []byte{1, 2, 3, 4}, someSig) {
t.Error("failed to use priv")
}
}

View File

@@ -6,10 +6,8 @@ package controlclient
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/url"
@@ -17,9 +15,9 @@ import (
"time"
"golang.org/x/net/http2"
"tailscale.com/control/controlbase"
"tailscale.com/control/controlhttp"
"tailscale.com/health"
"tailscale.com/internal/noiseconn"
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
@@ -32,113 +30,6 @@ import (
"tailscale.com/util/singleflight"
)
// noiseConn is a wrapper around controlbase.Conn.
// It allows attaching an ID to a connection to allow
// cleaning up references in the pool when the connection
// is closed.
type noiseConn struct {
*controlbase.Conn
id int
pool *NoiseClient
h2cc *http2.ClientConn
readHeaderOnce sync.Once // guards init of reader field
reader io.Reader // (effectively Conn.Reader after header)
earlyPayloadReady chan struct{} // closed after earlyPayload is set (including set to nil)
earlyPayload *tailcfg.EarlyNoise
earlyPayloadErr error
}
func (c *noiseConn) RoundTrip(r *http.Request) (*http.Response, error) {
return c.h2cc.RoundTrip(r)
}
// getEarlyPayload waits for the early noise payload to arrive.
// It may return (nil, nil) if the server begins HTTP/2 without one.
func (c *noiseConn) getEarlyPayload(ctx context.Context) (*tailcfg.EarlyNoise, error) {
select {
case <-c.earlyPayloadReady:
return c.earlyPayload, c.earlyPayloadErr
case <-ctx.Done():
return nil, ctx.Err()
}
}
// The first 9 bytes from the server to client over Noise are either an HTTP/2
// settings frame (a normal HTTP/2 setup) or, as we added later, an "early payload"
// header that's also 9 bytes long: 5 bytes (earlyPayloadMagic) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded tailcfg.EarlyNoise.
// The early payload is optional. Some servers may not send it.
const (
hdrLen = 9 // http2 frame header size; also size of our early payload size header
earlyPayloadMagic = "\xff\xff\xffTS"
)
// returnErrReader is an io.Reader that always returns an error.
type returnErrReader struct {
err error // the error to return
}
func (r returnErrReader) Read([]byte) (int, error) { return 0, r.err }
// Read is basically the same as controlbase.Conn.Read, but it first reads the
// "early payload" header from the server which may or may not be present,
// depending on the server.
func (c *noiseConn) Read(p []byte) (n int, err error) {
c.readHeaderOnce.Do(c.readHeader)
return c.reader.Read(p)
}
// readHeader reads the optional "early payload" from the server that arrives
// after the Noise handshake but before the HTTP/2 session begins.
//
// readHeader is responsible for reading the header (if present), initializing
// c.earlyPayload, closing c.earlyPayloadReady, and initializing c.reader for
// future reads.
func (c *noiseConn) readHeader() {
defer close(c.earlyPayloadReady)
setErr := func(err error) {
c.reader = returnErrReader{err}
c.earlyPayloadErr = err
}
var hdr [hdrLen]byte
if _, err := io.ReadFull(c.Conn, hdr[:]); err != nil {
setErr(err)
return
}
if string(hdr[:len(earlyPayloadMagic)]) != earlyPayloadMagic {
// No early payload. We have to return the 9 bytes read we already
// consumed.
c.reader = io.MultiReader(bytes.NewReader(hdr[:]), c.Conn)
return
}
epLen := binary.BigEndian.Uint32(hdr[len(earlyPayloadMagic):])
if epLen > 10<<20 {
setErr(errors.New("invalid early payload length"))
return
}
payBuf := make([]byte, epLen)
if _, err := io.ReadFull(c.Conn, payBuf); err != nil {
setErr(err)
return
}
if err := json.Unmarshal(payBuf, &c.earlyPayload); err != nil {
setErr(err)
return
}
c.reader = c.Conn
}
func (c *noiseConn) Close() error {
if err := c.Conn.Close(); err != nil {
return err
}
c.pool.connClosed(c.id)
return nil
}
// NoiseClient provides a http.Client to connect to tailcontrol over
// the ts2021 protocol.
type NoiseClient struct {
@@ -158,7 +49,7 @@ type NoiseClient struct {
// sfDial ensures that two concurrent requests for a noise connection only
// produce one shared one between the two callers.
sfDial singleflight.Group[struct{}, *noiseConn]
sfDial singleflight.Group[struct{}, *noiseconn.Conn]
dialer *tsdial.Dialer
dnsCache *dnscache.Resolver
@@ -180,9 +71,9 @@ type NoiseClient struct {
// mu only protects the following variables.
mu sync.Mutex
closed bool
last *noiseConn // or nil
last *noiseconn.Conn // or nil
nextID int
connPool map[int]*noiseConn // active connections not yet closed; see noiseConn.Close
connPool map[int]*noiseconn.Conn // active connections not yet closed; see noiseconn.Conn.Close
}
// NoiseOpts contains options for the NewNoiseClient function. All fields are
@@ -283,11 +174,11 @@ func (nc *NoiseClient) GetSingleUseRoundTripper(ctx context.Context) (http.Round
if err != nil {
return nil, nil, err
}
earlyPayloadMaybeNil, err := conn.getEarlyPayload(ctx)
ok, earlyPayloadMaybeNil, err := conn.ReserveNewRequest(ctx)
if err != nil {
return nil, nil, err
}
if conn.h2cc.ReserveNewRequest() {
if ok {
return conn, earlyPayloadMaybeNil, nil
}
}
@@ -308,14 +199,14 @@ func (e contextErr) Unwrap() error {
return e.err
}
// getConn returns a noiseConn that can be used to make requests to the
// getConn returns a noiseconn.Conn that can be used to make requests to the
// coordination server. It may return a cached connection or create a new one.
// Dials are singleflighted, so concurrent calls to getConn may only dial once.
// As such, context values may not be respected as there are no guarantees that
// the context passed to getConn is the same as the context passed to dial.
func (nc *NoiseClient) getConn(ctx context.Context) (*noiseConn, error) {
func (nc *NoiseClient) getConn(ctx context.Context) (*noiseconn.Conn, error) {
nc.mu.Lock()
if last := nc.last; last != nil && last.canTakeNewRequest() {
if last := nc.last; last != nil && last.CanTakeNewRequest() {
nc.mu.Unlock()
return last, nil
}
@@ -327,7 +218,7 @@ func (nc *NoiseClient) getConn(ctx context.Context) (*noiseConn, error) {
// canceled. Instead, we have to additionally check that the context
// which was canceled is our context and retry if our context is still
// valid.
conn, err, _ := nc.sfDial.Do(struct{}{}, func() (*noiseConn, error) {
conn, err, _ := nc.sfDial.Do(struct{}{}, func() (*noiseconn.Conn, error) {
c, err := nc.dial(ctx)
if err != nil {
if ctx.Err() != nil {
@@ -395,7 +286,7 @@ func (nc *NoiseClient) Close() error {
// dial opens a new connection to tailcontrol, fetching the server noise key
// if not cached.
func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
func (nc *NoiseClient) dial(ctx context.Context) (*noiseconn.Conn, error) {
nc.mu.Lock()
connID := nc.nextID
nc.nextID++
@@ -465,18 +356,10 @@ func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
return nil, err
}
ncc := &noiseConn{
Conn: clientConn.Conn,
id: connID,
pool: nc,
earlyPayloadReady: make(chan struct{}),
}
h2cc, err := nc.h2t.NewClientConn(ncc)
ncc, err := noiseconn.New(clientConn.Conn, nc.h2t, connID, nc.connClosed)
if err != nil {
return nil, err
}
ncc.h2cc = h2cc
nc.mu.Lock()
if nc.closed {
@@ -485,7 +368,7 @@ func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
return nil, errors.New("noise client closed")
}
defer nc.mu.Unlock()
mak.Set(&nc.connPool, ncc.id, ncc)
mak.Set(&nc.connPool, connID, ncc)
nc.last = ncc
return ncc, nil
}
@@ -508,9 +391,5 @@ func (nc *NoiseClient) post(ctx context.Context, path string, nodeKey key.NodePu
if err != nil {
return nil, err
}
return conn.h2cc.RoundTrip(req)
}
func (c *noiseConn) canTakeNewRequest() bool {
return c.h2cc.CanTakeNewRequest()
return conn.RoundTrip(req)
}

View File

@@ -16,6 +16,7 @@ import (
"golang.org/x/net/http2"
"tailscale.com/control/controlhttp"
"tailscale.com/internal/noiseconn"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
@@ -93,22 +94,21 @@ func (tt noiseClientTest) run(t *testing.T) {
if err != nil {
t.Fatal(err)
}
select {
case <-c.earlyPayloadReady:
gotNonNil := c.earlyPayload != nil
if gotNonNil != tt.sendEarlyPayload {
t.Errorf("sendEarlyPayload = %v but got earlyPayload = %T", tt.sendEarlyPayload, c.earlyPayload)
}
if c.earlyPayload != nil {
if c.earlyPayload.NodeKeyChallenge != chalPrivate.Public() {
t.Errorf("earlyPayload.NodeKeyChallenge = %v; want %v", c.earlyPayload.NodeKeyChallenge, chalPrivate.Public())
}
}
case <-ctx.Done():
payload, err := c.GetEarlyPayload(ctx)
if err != nil {
t.Fatal("timed out waiting for didReadHeaderCh")
}
gotNonNil := payload != nil
if gotNonNil != tt.sendEarlyPayload {
t.Errorf("sendEarlyPayload = %v but got earlyPayload = %T", tt.sendEarlyPayload, payload)
}
if payload != nil {
if payload.NodeKeyChallenge != chalPrivate.Public() {
t.Errorf("earlyPayload.NodeKeyChallenge = %v; want %v", payload.NodeKeyChallenge, chalPrivate.Public())
}
}
checkRes := func(t *testing.T, res *http.Response) {
t.Helper()
defer res.Body.Close()
@@ -184,7 +184,7 @@ func (up *Upgrader) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// https://httpwg.org/specs/rfc7540.html#rfc.section.4.1 (Especially not
// an HTTP/2 settings frame, which isn't of type 'T')
var notH2Frame [5]byte
copy(notH2Frame[:], earlyPayloadMagic)
copy(notH2Frame[:], noiseconn.EarlyPayloadMagic)
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(earlyJSON)))
// These writes are all buffered by caller, so fine to do them

View File

@@ -32,18 +32,21 @@ import (
"net/http/httptrace"
"net/netip"
"net/url"
"runtime"
"sort"
"sync/atomic"
"time"
"tailscale.com/control/controlbase"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/net/dnscache"
"tailscale.com/net/dnsfallback"
"tailscale.com/net/netutil"
"tailscale.com/net/sockstats"
"tailscale.com/net/tlsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/util/multierr"
@@ -396,12 +399,29 @@ func (a *Dialer) resolver() *dnscache.Resolver {
}
}
func isLoopback(a net.Addr) bool {
if ta, ok := a.(*net.TCPAddr); ok {
return ta.IP.IsLoopback()
}
return false
}
var macOSScreenTime = health.Register(&health.Warnable{
Code: "macos-screen-time",
Severity: health.SeverityHigh,
Title: "Tailscale blocked by Screen Time",
Text: func(args health.Args) string {
return "macOS Screen Time seems to be blocking Tailscale. Try disabling Screen Time in System Settings > Screen Time > Content & Privacy > Access to Web Content."
},
ImpactsConnectivity: true,
})
// tryURLUpgrade connects to u, and tries to upgrade it to a net.Conn. If addr
// is valid, then no DNS is used and the connection will be made to the
// provided address.
//
// Only the provided ctx is used, not a.ctx.
func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr, init []byte) (net.Conn, error) {
func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr, init []byte) (_ net.Conn, retErr error) {
var dns *dnscache.Resolver
// If we were provided an address to dial, then create a resolver that just
@@ -423,6 +443,30 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
dialer = stdDialer.DialContext
}
// On macOS, see if Screen Time is blocking things.
if runtime.GOOS == "darwin" {
var proxydIntercepted atomic.Bool // intercepted by macOS webfilterproxyd
origDialer := dialer
dialer = func(ctx context.Context, network, address string) (net.Conn, error) {
c, err := origDialer(ctx, network, address)
if err != nil {
return nil, err
}
if isLoopback(c.LocalAddr()) && isLoopback(c.RemoteAddr()) {
proxydIntercepted.Store(true)
}
return c, nil
}
defer func() {
if retErr != nil && proxydIntercepted.Load() {
a.HealthTracker.SetUnhealthy(macOSScreenTime, nil)
retErr = fmt.Errorf("macOS Screen Time is blocking network access: %w", retErr)
} else {
a.HealthTracker.SetHealthy(macOSScreenTime)
}
}()
}
tr := http.DefaultTransport.(*http.Transport).Clone()
defer tr.CloseIdleConnections()
tr.Proxy = a.getProxyFunc()
@@ -454,11 +498,9 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
tr.DisableCompression = true
// (mis)use httptrace to extract the underlying net.Conn from the
// transport. We make exactly 1 request using this transport, so
// there will be exactly 1 GotConn call. Additionally, the
// transport handles 101 Switching Protocols correctly, such that
// the Conn will not be reused or kept alive by the transport once
// the response has been handed back from RoundTrip.
// transport. The transport handles 101 Switching Protocols correctly,
// such that the Conn will not be reused or kept alive by the transport
// once the response has been handed back from RoundTrip.
//
// In theory, the machinery of net/http should make it such that
// the trace callback happens-before we get the response, but
@@ -474,10 +516,16 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
// unexpected EOFs...), and we're bound to forget someday and
// introduce a protocol optimization at a higher level that starts
// eagerly transmitting from the server.
connCh := make(chan net.Conn, 1)
var lastConn syncs.AtomicValue[net.Conn]
trace := httptrace.ClientTrace{
// Even though we only make a single HTTP request which should
// require a single connection, the context (with the attached
// trace configuration) might be used by our custom dialer to
// make other HTTP requests (e.g. BootstrapDNS). We only care
// about the last connection made, which should be the one to
// the control server.
GotConn: func(info httptrace.GotConnInfo) {
connCh <- info.Conn
lastConn.Store(info.Conn)
},
}
ctx = httptrace.WithClientTrace(ctx, &trace)
@@ -505,11 +553,7 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
// is still a read buffer attached to it within resp.Body. So, we
// must direct I/O through resp.Body, but we can still use the
// underlying net.Conn for stuff like deadlines.
var switchedConn net.Conn
select {
case switchedConn = <-connCh:
default:
}
switchedConn := lastConn.Load()
if switchedConn == nil {
resp.Body.Close()
return nil, fmt.Errorf("httptrace didn't provide a connection")

View File

@@ -10,7 +10,7 @@ import (
"net"
"net/url"
"nhooyr.io/websocket"
"github.com/coder/websocket"
"tailscale.com/control/controlbase"
"tailscale.com/net/wsconn"
)

View File

@@ -11,10 +11,12 @@ import (
"log"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/netip"
"net/url"
"runtime"
"slices"
"strconv"
"sync"
"testing"
@@ -41,6 +43,8 @@ type httpTestParam struct {
makeHTTPHangAfterUpgrade bool
doEarlyWrite bool
httpInDial bool
}
func TestControlHTTP(t *testing.T) {
@@ -120,6 +124,12 @@ func TestControlHTTP(t *testing.T) {
name: "early_write",
doEarlyWrite: true,
},
// Dialer needed to make another HTTP request along the way (e.g. to
// resolve the hostname via BootstrapDNS).
{
name: "http_request_in_dial",
httpInDial: true,
},
}
for _, test := range tests {
@@ -217,6 +227,29 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
Clock: clock,
}
if param.httpInDial {
// Spin up a separate server to get a different port on localhost.
secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return }))
defer secondServer.Close()
prev := a.Dialer
a.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", secondServer.URL, nil)
if err != nil {
t.Errorf("http.NewRequest: %v", err)
}
r, err := http.DefaultClient.Do(req)
if err != nil {
t.Errorf("http.Get: %v", err)
}
r.Body.Close()
return prev(ctx, network, addr)
}
}
if proxy != nil {
proxyEnv := proxy.Start(t)
defer proxy.Close()
@@ -238,6 +271,7 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
t.Fatalf("dialing controlhttp: %v", err)
}
defer conn.Close()
si := <-sch
if si.conn != nil {
defer si.conn.Close()
@@ -266,6 +300,19 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
t.Errorf("early write = %q; want %q", buf, earlyWriteMsg)
}
}
// When no proxy is used, the RemoteAddr of the returned connection should match
// one of the listeners of the test server.
if proxy == nil {
var expectedAddrs []string
for _, ln := range []net.Listener{httpLn, httpsLn} {
expectedAddrs = append(expectedAddrs, fmt.Sprintf("127.0.0.1:%d", ln.Addr().(*net.TCPAddr).Port))
expectedAddrs = append(expectedAddrs, fmt.Sprintf("[::1]:%d", ln.Addr().(*net.TCPAddr).Port))
}
if !slices.Contains(expectedAddrs, conn.RemoteAddr().String()) {
t.Errorf("unexpected remote addr: %s, want %s", conn.RemoteAddr(), expectedAddrs)
}
}
}
type serverResult struct {

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