Compare commits

...

44 Commits

Author SHA1 Message Date
Brad Fitzpatrick
5b781a70e8 wgengine/magicsock: fix typo in comment
Change-Id: I53fdc085ecb2c9123b21429b3d2ae80a06f22df2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:42:11 -07:00
Brad Fitzpatrick
261776e2e4 tempfork/netshell: delete, use github.com/go4org/plan9netshell instead
Change-Id: I2aee6b55f2084a7c3df0db830b28edd1fba0f528
2025-03-24 13:39:43 -07:00
Brad Fitzpatrick
391edc4712 ssh/tailssh: fix non-interactive ssh commands on plan9
Change-Id: Ic026db0548aa1ed8d25619cd98a85f031e987a33
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:39:43 -07:00
Brad Fitzpatrick
a193323479 net/dns: clean up plan9 manager
all: clean up logging noise

Change-Id: Id7a777b5697fb63cf8690fe971e1ece97427f678
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:39:43 -07:00
Brad Fitzpatrick
478028fce8 net/dns: more plan9 debugging
Change-Id: I9864835ffca4c4b14ec7c75b31e61ed30105c067
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:39:43 -07:00
Brad Fitzpatrick
835c7e1e90 net/dns: add more of DNS manager for plan9, tests, plumb netmon/tun name around more
Change-Id: Ia542d7c69f3fbcd2571e2da8b04ad34ec0e2645d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:39:43 -07:00
Brad Fitzpatrick
abb77602a4 more plan9 fixes, to be cleaned up and merged separately later
Change-Id: I4c89609d7eb83bbe208aa87025365427d961b048
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:39:42 -07:00
Brad Fitzpatrick
0d67d7835a some more plan9 tweaks; notably DNS one
Change-Id: Ib14f2663d0f36623a42ce95574be61f0b9df1b14
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
9a0302a454 portlist: add plan9 impl
Change-Id: Ib69b02ad25dc3b94bc6e407aa7ace842a3e51fda
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
e62920165b fix pty dep back to what it was
Change-Id: I418d4ef0fd33e927e953c35ab028e02f84692243
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
0a847f04f3 more plan9 work
Change-Id: I1f6bd742130f348917df3a00b95b0997f84ba1b4
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
6a3f589f53 more plan9 ssh work
Change-Id: I7049b5b4f6c0f9902693e00ab6d12ac00bc554bc
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
dd3d4e3fe9 ssh/tailssh: add plan9 incubator
wgengine/router: add missing router file

Change-Id: I1b6573ea5e2d3ab23f1d297209f822adee7b10e0
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
f3011fce04 more misc plan9 work; it works now
Change-Id: Iafc4ee41dd4d7ba9b9907c8e13bb1e161c8f78e3
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
8a9bc098ab ssh/tailssh: work on Tailscale SSH for plan9
Updates #5794

Change-Id: I888a7a47752e09005f6d9e8a8412b42df19c5c89
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
af61219a15 Makefile: add temporary plan9 rule
Updates #5794

Change-Id: I89113223560ae5734fca9f460e6a6f77d5c82135
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
9ef648c8df cmd/tailscaled: add default state dir on plan9
Updates #5794

Change-Id: I4729881a36276a5c39233b4aef9550230ccc9316
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
dc7c15fb84 safesocket: don't use srv(3) on plan9
The implementation wasn't right and spun up
infinite goroutines. Thanks to Russ Cox for debugging.

Updates #5794

Change-Id: I21048712401492829009ad6864cc71c6edf61a64
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
7a5633a859 logpolicy: disable logs on plan9 for now
They're just distracting at the moment.

Updates #5794

Change-Id: I3e3580ec4b31401c5fea24381387958bd730fba6
2025-03-24 13:38:39 -07:00
Brad Fitzpatrick
14db99241f net/netmon: use Monitor's tsIfName if set by SetTailscaleInterfaceName
Currently nobody calls SetTailscaleInterfaceName yet, so this is a
no-op. I checked oss, android, and the macOS/iOS client. Nobody calls
this, or ever did.

But I want to in the future.

Updates #15408
Updates #9040

Change-Id: I05dfabe505174f9067b929e91c6e0d8bc42628d7
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 13:34:02 -07:00
Brad Fitzpatrick
156cd53e77 net/netmon: unexport GetState
Baby step towards #15408.

Updates #15408

Change-Id: I11fca6e677af2ad2f065d83aa0d83550143bff29
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 10:43:15 -07:00
Brad Fitzpatrick
5c0e08fbbd tstest/mts: add multiple-tailscaled development tool
To let you easily run multiple tailscaled instances for development
and let you route CLI commands to the right one.

Updates #15145

Change-Id: I06b6a7bf024f341c204f30705b4c3068ac89b1a2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 10:10:35 -07:00
Brad Fitzpatrick
d0c50c6072 clientupdate: cache CanAutoUpdate, avoid log spam when false
I noticed logs on one of my machines where it can't auto-update with
scary log spam about "failed to apply tailnet-wide default for
auto-updates".

This avoids trying to do the EditPrefs if we know it's just going to
fail anyway.

Updates #282

Change-Id: Ib7db3b122185faa70efe08b60ebd05a6094eed8c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-24 09:46:48 -07:00
Simon Law
6bbf98bef4 all: skip looking for package comments in .git/ repository (#15384) 2025-03-21 14:46:02 -07:00
Brad Fitzpatrick
e1078686b3 safesocket: respect context timeout when sleeping for 250ms in retry loop
Noticed while working on a dev tool that uses local.Client.

Updates #cleanup

Change-Id: I981efff74a5cac5f515755913668bd0508a4aa14
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-21 10:55:32 -07:00
James Sanderson
c261fb198f tstest: make it clearer where AwaitRunning failed and why
Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2025-03-21 13:09:46 +00:00
James Sanderson
5668de272c tsnet: use test logger for testcontrol and node logs
Updates #cleanup

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2025-03-21 12:33:36 +00:00
Tom Proctor
005e20a45e cmd/k8s-operator,internal/client/tailscale: use VIPService annotations for ownership tracking (#15356)
Switch from using the Comment field to a ts-scoped annotation for
tracking which operators are cooperating over ownership of a
VIPService.

Updates tailscale/corp#24795

Change-Id: I72d4a48685f85c0329aa068dc01a1a3c749017bf
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-03-21 09:08:39 +00:00
Irbe Krumina
196ae1cd74 cmd/k8s-operator,k8s-operator: allow optionally using LE staging endpoint for Ingress (#15360)
cmd/k8s-operator,k8s-operator: allow using LE staging endpoint for Ingress

Allow to optionally use LetsEncrypt staging endpoint to issue
certs for Ingress/HA Ingress, so that it is easier to
experiment with initial Ingress setup without hiting rate limits.

Updates tailscale/corp#24795


Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-03-21 08:53:41 +00:00
Nick Khyl
f3f2f72f96 ipn/ipnlocal: do not attempt to start the auditlogger with a nil transport
(*LocalBackend).setControlClientLocked() is called to both set and reset b.cc.
We shouldn't attempt to start the audit logger when b.cc is being reset (i.e., cc is nil).

However, it's fine to start the audit logger if b.cc implements auditlog.Transport, even if it's not a controlclient.Auto but a mock control client.

In this PR, we fix both issues and add an assertion that controlclient.Auto is an auditlog.Transport. This ensures a compile-time failure if controlclient.Auto ever stops being a valid transport due to future interface or implementation changes.

Updates tailscale/corp#26435

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-03-20 15:56:54 -05:00
Nick Khyl
e07c1573f6 ipn/ipnlocal: do not reset the netmap and packet filter in (*LocalBackend).Start()
Resetting LocalBackend's netmap without also unconfiguring wgengine to reset routes, DNS, and the killswitch
firewall rules may cause connectivity issues until a new netmap is received.

In some cases, such as when bootstrap DNS servers are inaccessible due to network restrictions or other reasons,
or if the control plane is experiencing issues, this can result in a complete loss of connectivity until the user disconnects
and reconnects to Tailscale.

As LocalBackend handles state resets in (*LocalBackend).resetForProfileChangeLockedOnEntry(), and this includes
resetting the netmap, resetting the current netmap in (*LocalBackend).Start() is not necessary.
Moreover, it's harmful if (*LocalBackend).Start() is called more than once for the same profile.

In this PR, we update resetForProfileChangeLockedOnEntry() to reset the packet filter and remove
the redundant resetting of the netmap and packet filter from Start(). We also update the state machine
tests and revise comments that became inaccurate due to previous test updates.

Updates tailscale/corp#27173

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-03-20 13:18:23 -05:00
Brad Fitzpatrick
984cd1cab0 cmd/tailscale: add CLI debug command to do raw LocalAPI requests
This adds a portable way to do a raw LocalAPI request without worrying
about the Unix-vs-macOS-vs-Windows ways of hitting the LocalAPI server.
(It was already possible but tedious with 'tailscale debug local-creds')

Updates tailscale/corp#24690

Change-Id: I0828ca55edaedf0565c8db192c10f24bebb95f1b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-03-20 10:07:11 -07:00
Irbe Krumina
f34e08e186 ipn: ensure that conffile is source of truth for advertised services. (#15361)
If conffile is used to configure tailscaled, always update
currently advertised services from conffile, even if they
are empty in the conffile, to ensure that it is possible
to transition to a state where no services are advertised.

Updates tailscale/corp#24795

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-03-20 14:40:36 +00:00
klyubin
3a2c92f08e web: support Host 100.100.100.100:80 in tailscaled web server
This makes the web server running inside tailscaled on 100.100.100.100:80 support requests with `Host: 100.100.100.100:80` and its IPv6 equivalent.

Prior to this commit, the web server replied to such requests with a redirect to the node's Tailscale IP:5252.

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

Signed-off-by: Alex Klyubin <klyubin@gmail.com>
2025-03-19 16:46:32 +00:00
Tom Proctor
8d84720edb cmd/k8s-operator: update ProxyGroup config Secrets instead of patch (#15353)
There was a flaky failure case where renaming a TLS hostname for an
ingress might leave the old hostname dangling in tailscaled config. This
happened when the proxygroup reconciler loop had an outdated resource
version of the config Secret in its cache after the
ingress-pg-reconciler loop had very recently written it to delete the
old hostname. As the proxygroup reconciler then did a patch, there was
no conflict and it reinstated the old hostname.

This commit updates the patch to an update operation so that if the
resource version is out of date it will fail with an optimistic lock
error. It also checks for equality to reduce the likelihood that we make
the update API call in the first place, because most of the time the
proxygroup reconciler is not even making an update to the Secret in the
case that the hostname has changed.

Updates tailscale/corp#24795

Change-Id: Ie23a97440063976c9a8475d24ab18253e1f89050
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-03-19 13:49:36 +00:00
Jonathan Nobels
25d5f78c6e net/dns: expose a function for recompiling the DNS configuration (#15346)
updates tailscale/corp#27145

We require a means to trigger a recompilation of the DNS configuration
to pick up new nameservers for platforms where we blend the interface
nameservers from the OS into our DNS config.

Notably, on Darwin, the only API we have at our disposal will, in rare instances,
return a transient error when querying the interface nameservers on a link change if
they have not been set when we get the AF_ROUTE messages for the link
update.

There's a corresponding change in corp for Darwin clients, to track
the interface namservers during NEPathMonitor events, and call this
when the nameservers change.

This will also fix the slightly more obscure bug of changing nameservers
 while tailscaled is running.  That change can now be reflected in
magicDNS without having to stop the client.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2025-03-19 09:21:37 -04:00
Irbe Krumina
f50d3b22db cmd/k8s-operator: configure proxies for HA Ingress to run in cert share mode (#15308)
cmd/k8s-operator: configure HA Ingress replicas to share certs

Creates TLS certs Secret and RBAC that allows HA Ingress replicas
to read/write to the Secret.
Configures HA Ingress replicas to run in read-only mode.

Updates tailscale/corp#24795


Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-03-19 12:49:31 +00:00
Tom Proctor
b0095a5da4 cmd/k8s-operator: wait for VIPService before updating HA Ingress status (#15343)
Update the HA Ingress controller to wait until it sees AdvertisedServices
config propagated into at least 1 Pod's prefs before it updates the status
on the Ingress, to ensure the ProxyGroup Pods are ready to serve traffic
before indicating that the Ingress is ready

Updates tailscale/corp#24795

Change-Id: I1b8ce23c9e312d08f9d02e48d70bdebd9e1a4757

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-03-19 08:53:15 +00:00
David Anderson
e091e71937 util/eventbus: remove debug UI from iOS build
The use of html/template causes reflect-based linker bloat. Longer
term we have options to bring the UI back to iOS, but for now, cut
it out.

Updates #15297

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-18 17:04:15 -07:00
David Anderson
daa5635ba6 tsweb: split promvarz into an optional dependency
Allows the use of tsweb without pulling in all of the heavy prometheus
client libraries, protobuf and so on.

Updates #15160

Signed-off-by: David Anderson <dave@tailscale.com>
2025-03-18 16:57:04 -07:00
Anton Tolchanov
74ee749386 client/tailscale: add tailnet lock fields to Device struct
These are documented, but have not yet been defined in the client.
https://tailscale.com/api#tag/devices/GET/device/{deviceId}

Updates tailscale/corp#27050

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2025-03-18 17:03:19 +00:00
Irbe Krumina
34734ba635 ipn/store/kubestore,kube,envknob,cmd/tailscaled/depaware.txt: allow kubestore read/write custom TLS secrets (#15307)
This PR adds some custom logic for reading and writing
kube store values that are TLS certs and keys:
1) when store is initialized, lookup additional
TLS Secrets for this node and if found, load TLS certs
from there
2) if the node runs in certs 'read only' mode and
TLS cert and key are not found in the in-memory store,
look those up in a Secret
3) if the node runs in certs 'read only' mode, run
a daily TLS certs reload to memory to get any
renewed certs

Updates tailscale/corp#24795

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-03-18 15:09:22 +00:00
Tom Proctor
ef1e14250c cmd/k8s-operator: ensure old VIPServices are cleaned up (#15344)
When the Ingress is updated to a new hostname, the controller does not
currently clean up the old VIPService from control. Fix this up to parse
the ownership comment correctly and write a test to enforce the improved
behaviour

Updates tailscale/corp#24795

Change-Id: I792ae7684807d254bf2d3cc7aa54aa04a582d1f5

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-03-18 12:48:59 +00:00
Anton Tolchanov
b413b70ae2 cmd/proxy-to-grafana: support setting Grafana role via grants
This adds support for using ACL Grants to configure a role for the
auto-provisioned user.

Fixes tailscale/corp#14567

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2025-03-18 07:26:04 +00:00
97 changed files with 4126 additions and 726 deletions

View File

@@ -110,6 +110,15 @@ publishdevnameserver: ## Build and publish k8s-nameserver image to location spec
@test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh
plan93:
GOOS=plan9 GOARCH=386 ${HOME}/hack/go/bin/go build -o ${HOME}/hack/rsc-plan9/386/bin/tailscaled ./cmd/tailscaled
GOOS=plan9 GOARCH=386 ${HOME}/hack/go/bin/go build -o ${HOME}/hack/rsc-plan9/386/bin/tailscale ./cmd/tailscale
plan9a:
GOOS=plan9 GOARCH=amd64 ${HOME}/hack/go/bin/go build -o ${HOME}/hack/rsc-plan9/amd64/bin/tailscaled ./cmd/tailscaled
GOOS=plan9 GOARCH=amd64 ${HOME}/hack/go/bin/go build -o ${HOME}/hack/rsc-plan9/amd64/bin/tailscale ./cmd/tailscale
.PHONY: sshintegrationtest
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \

View File

@@ -79,6 +79,13 @@ type Device struct {
// Tailscale have attempted to collect this from the device but it has not
// opted in, PostureIdentity will have Disabled=true.
PostureIdentity *DevicePostureIdentity `json:"postureIdentity"`
// TailnetLockKey is the tailnet lock public key of the node as a hex string.
TailnetLockKey string `json:"tailnetLockKey,omitempty"`
// TailnetLockErr indicates an issue with the tailnet lock node-key signature
// on this device. This field is only populated when tailnet lock is enabled.
TailnetLockErr string `json:"tailnetLockError,omitempty"`
}
type DevicePostureIdentity struct {

View File

@@ -335,7 +335,8 @@ func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (han
ipv6ServiceHost = "[" + tsaddr.TailscaleServiceIPv6String + "]"
)
// allow requests on quad-100 (or ipv6 equivalent)
if r.Host == ipv4ServiceHost || r.Host == ipv6ServiceHost {
host := strings.TrimSuffix(r.Host, ":80")
if host == ipv4ServiceHost || host == ipv6ServiceHost {
return false
}

View File

@@ -1177,6 +1177,16 @@ func TestRequireTailscaleIP(t *testing.T) {
target: "http://[fd7a:115c:a1e0::53]/",
wantHandled: false,
},
{
name: "quad-100:80",
target: "http://100.100.100.100:80/",
wantHandled: false,
},
{
name: "ipv6-service-addr:80",
target: "http://[fd7a:115c:a1e0::53]:80/",
wantHandled: false,
},
}
for _, tt := range tests {

View File

@@ -28,6 +28,7 @@ import (
"strings"
"tailscale.com/hostinfo"
"tailscale.com/types/lazy"
"tailscale.com/types/logger"
"tailscale.com/util/cmpver"
"tailscale.com/version"
@@ -249,9 +250,13 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
return nil, false
}
var canAutoUpdateCache lazy.SyncValue[bool]
// CanAutoUpdate reports whether auto-updating via the clientupdate package
// is supported for the current os/distro.
func CanAutoUpdate() bool {
func CanAutoUpdate() bool { return canAutoUpdateCache.Get(canAutoUpdateUncached) }
func canAutoUpdateUncached() bool {
if version.IsMacSysExt() {
// Macsys uses Sparkle for auto-updates, which doesn't have an update
// function in this package.

View File

@@ -96,6 +96,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/disco from tailscale.com/derp
tailscale.com/drive from tailscale.com/client/local+
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/feature from tailscale.com/tsweb
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/hostinfo from tailscale.com/net/netmon+
tailscale.com/ipn from tailscale.com/client/local
@@ -128,8 +129,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/tsweb from tailscale.com/cmd/derper
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
tailscale.com/tsweb from tailscale.com/cmd/derper+
tailscale.com/tsweb/promvarz from tailscale.com/cmd/derper
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/dnstype from tailscale.com/tailcfg+
tailscale.com/types/empty from tailscale.com/ipn
@@ -309,7 +310,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
html from net/http/pprof+
html/template from tailscale.com/cmd/derper
internal/abi from crypto/x509/internal/macos+
internal/asan from syscall+
internal/asan from internal/runtime/maps+
internal/bisect from internal/godebug
internal/bytealg from bytes+
internal/byteorder from crypto/cipher+
@@ -319,12 +320,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
internal/filepathlite from os+
internal/fmtsort from fmt+
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from crypto/tls+
internal/godebug from crypto/internal/fips140deps/godebug+
internal/godebugs from internal/godebug+
internal/goexperiment from runtime+
internal/goexperiment from hash/maphash+
internal/goos from crypto/x509+
internal/itoa from internal/poll+
internal/msan from syscall+
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from io/fs+
internal/poll from net+

View File

@@ -49,6 +49,9 @@ import (
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/version"
// Support for prometheus varz in tsweb
_ "tailscale.com/tsweb/promvarz"
)
var (

View File

@@ -15,6 +15,9 @@ import (
"tailscale.com/prober"
"tailscale.com/tsweb"
"tailscale.com/version"
// Support for prometheus varz in tsweb
_ "tailscale.com/tsweb/promvarz"
)
var (

View File

@@ -1151,7 +1151,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
html from html/template+
html/template from github.com/gorilla/csrf
internal/abi from crypto/x509/internal/macos+
internal/asan from syscall+
internal/asan from internal/runtime/maps+
internal/bisect from internal/godebug
internal/bytealg from bytes+
internal/byteorder from crypto/cipher+
@@ -1163,11 +1163,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from archive/tar+
internal/godebugs from internal/godebug+
internal/goexperiment from runtime+
internal/goexperiment from hash/maphash+
internal/goos from crypto/x509+
internal/itoa from internal/poll+
internal/lazyregexp from go/doc
internal/msan from syscall+
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from io/fs+
internal/poll from net+

View File

@@ -75,7 +75,7 @@ rules:
verbs: ["get", "list", "watch", "create", "update", "deletecollection"]
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
verbs: ["get", "create", "patch", "update", "list", "watch"]
verbs: ["get", "create", "patch", "update", "list", "watch", "deletecollection"]
- apiGroups: ["monitoring.coreos.com"]
resources: ["servicemonitors"]
verbs: ["get", "list", "update", "create", "delete"]

View File

@@ -2215,6 +2215,22 @@ spec:
https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-devices
Defaults to false.
type: boolean
useLetsEncryptStagingEnvironment:
description: |-
Set UseLetsEncryptStagingEnvironment to true to issue TLS
certificates for any HTTPS endpoints exposed to the tailnet from
LetsEncrypt's staging environment.
https://letsencrypt.org/docs/staging-environment/
This setting only affects Tailscale Ingress resources.
By default Ingress TLS certificates are issued from LetsEncrypt's
production environment.
Changing this setting true -> false, will result in any
existing certs being re-issued from the production environment.
Changing this setting false (default) -> true, when certs have already
been provisioned from production environment will NOT result in certs
being re-issued from the staging environment before they need to be
renewed.
type: boolean
status:
description: |-
Status of the ProxyClass. This is set and managed automatically.

View File

@@ -2685,6 +2685,22 @@ spec:
Defaults to false.
type: boolean
type: object
useLetsEncryptStagingEnvironment:
description: |-
Set UseLetsEncryptStagingEnvironment to true to issue TLS
certificates for any HTTPS endpoints exposed to the tailnet from
LetsEncrypt's staging environment.
https://letsencrypt.org/docs/staging-environment/
This setting only affects Tailscale Ingress resources.
By default Ingress TLS certificates are issued from LetsEncrypt's
production environment.
Changing this setting true -> false, will result in any
existing certs being re-issued from the production environment.
Changing this setting false (default) -> true, when certs have already
been provisioned from production environment will NOT result in certs
being re-issued from the staging environment before they need to be
renewed.
type: boolean
type: object
status:
description: |-
@@ -4898,6 +4914,7 @@ rules:
- update
- list
- watch
- deletecollection
- apiGroups:
- monitoring.coreos.com
resources:

View File

@@ -22,6 +22,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstest"
"tailscale.com/types/ptr"
)
@@ -163,10 +164,10 @@ func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
Name: o.GetName(),
Namespace: "tailscale",
Labels: map[string]string{
LabelManaged: "true",
LabelParentName: o.GetName(),
LabelParentNamespace: o.GetNamespace(),
LabelParentType: typ,
kubetypes.LabelManaged: "true",
LabelParentName: o.GetName(),
LabelParentNamespace: o.GetNamespace(),
LabelParentType: typ,
},
},
Spec: corev1.ServiceSpec{

View File

@@ -112,9 +112,9 @@ func (er *egressPodsReconciler) Reconcile(ctx context.Context, req reconcile.Req
}
// Get all ClusterIP Services for all egress targets exposed to cluster via this ProxyGroup.
lbls := map[string]string{
LabelManaged: "true",
labelProxyGroup: proxyGroupName,
labelSvcType: typeEgress,
kubetypes.LabelManaged: "true",
labelProxyGroup: proxyGroupName,
labelSvcType: typeEgress,
}
svcs := &corev1.ServiceList{}
if err := er.List(ctx, svcs, client.InNamespace(er.tsNamespace), client.MatchingLabels(lbls)); err != nil {

View File

@@ -450,9 +450,9 @@ func newSvc(name string, port int32) (*corev1.Service, string) {
Namespace: "operator-ns",
Name: name,
Labels: map[string]string{
LabelManaged: "true",
labelProxyGroup: "dev",
labelSvcType: typeEgress,
kubetypes.LabelManaged: "true",
labelProxyGroup: "dev",
labelSvcType: typeEgress,
},
},
Spec: corev1.ServiceSpec{},

View File

@@ -680,12 +680,12 @@ func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, ts
// should probably validate and truncate (?) the names is they are too long.
func egressSvcChildResourceLabels(svc *corev1.Service) map[string]string {
return map[string]string{
LabelManaged: "true",
LabelParentType: "svc",
LabelParentName: svc.Name,
LabelParentNamespace: svc.Namespace,
labelProxyGroup: svc.Annotations[AnnotationProxyGroup],
labelSvcType: typeEgress,
kubetypes.LabelManaged: "true",
LabelParentType: "svc",
LabelParentName: svc.Name,
LabelParentNamespace: svc.Namespace,
labelProxyGroup: svc.Annotations[AnnotationProxyGroup],
labelSvcType: typeEgress,
}
}

View File

@@ -22,6 +22,7 @@ import (
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
rbacv1 "k8s.io/api/rbac/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -154,13 +155,13 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
pg := &tsapi.ProxyGroup{}
if err := r.Get(ctx, client.ObjectKey{Name: pgName}, pg); err != nil {
if apierrors.IsNotFound(err) {
logger.Infof("ProxyGroup %q does not exist", pgName)
logger.Infof("ProxyGroup does not exist")
return false, nil
}
return false, fmt.Errorf("getting ProxyGroup %q: %w", pgName, err)
}
if !tsoperator.ProxyGroupIsReady(pg) {
logger.Infof("ProxyGroup %q is not (yet) ready", pgName)
logger.Infof("ProxyGroup is not (yet) ready")
return false, nil
}
@@ -175,8 +176,6 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
r.recorder.Event(ing, corev1.EventTypeWarning, "HTTPSNotEnabled", "HTTPS is not enabled on the tailnet; ingress may not work")
}
logger = logger.With("proxy-group", pg.Name)
if !slices.Contains(ing.Finalizers, FinalizerNamePG) {
// This log line is printed exactly once during initial provisioning,
// because once the finalizer is in place this block gets skipped. So,
@@ -229,12 +228,11 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
return false, fmt.Errorf("error getting VIPService %q: %w", hostname, err)
}
}
// Generate the VIPService comment for new or existing VIPService. This
// checks and ensures that VIPService's owner references are updated for
// this Ingress and errors if that is not possible (i.e. because it
// appears that the VIPService has been created by a non-operator
// actor).
svcComment, err := r.ownerRefsComment(existingVIPSvc)
// Generate the VIPService owner annotation for new or existing VIPService.
// This checks and ensures that VIPService's owner references are updated
// for this Ingress and errors if that is not possible (i.e. because it
// appears that the VIPService has been created by a non-operator actor).
updatedAnnotations, err := r.ownerAnnotations(existingVIPSvc)
if err != nil {
const instr = "To proceed, you can either manually delete the existing VIPService or choose a different MagicDNS name at `.spec.tls.hosts[0] in the Ingress definition"
msg := fmt.Sprintf("error ensuring ownership of VIPService %s: %v. %s", hostname, err, instr)
@@ -242,8 +240,12 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
r.recorder.Event(ing, corev1.EventTypeWarning, "InvalidVIPService", msg)
return false, nil
}
// 3. Ensure that TLS Secret and RBAC exists
if err := r.ensureCertResources(ctx, pgName, dnsName); err != nil {
return false, fmt.Errorf("error ensuring cert resources: %w", err)
}
// 3. Ensure that the serve config for the ProxyGroup contains the VIPService.
// 4. Ensure that the serve config for the ProxyGroup contains the VIPService.
cm, cfg, err := r.proxyGroupServeConfig(ctx, pgName)
if err != nil {
return false, fmt.Errorf("error getting Ingress serve config: %w", err)
@@ -310,11 +312,13 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
vipPorts = append(vipPorts, "80")
}
const managedVIPServiceComment = "This VIPService is managed by the Tailscale Kubernetes Operator, do not modify"
vipSvc := &tailscale.VIPService{
Name: serviceName,
Tags: tags,
Ports: vipPorts,
Comment: svcComment,
Name: serviceName,
Tags: tags,
Ports: vipPorts,
Comment: managedVIPServiceComment,
Annotations: updatedAnnotations,
}
if existingVIPSvc != nil {
vipSvc.Addrs = existingVIPSvc.Addrs
@@ -325,8 +329,8 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
if existingVIPSvc == nil ||
!reflect.DeepEqual(vipSvc.Tags, existingVIPSvc.Tags) ||
!reflect.DeepEqual(vipSvc.Ports, existingVIPSvc.Ports) ||
!strings.EqualFold(vipSvc.Comment, existingVIPSvc.Comment) {
logger.Infof("Ensuring VIPService %q exists and is up to date", hostname)
!ownersAreSetAndEqual(vipSvc, existingVIPSvc) {
logger.Infof("Ensuring VIPService exists and is up to date")
if err := r.tsClient.CreateOrUpdateVIPService(ctx, vipSvc); err != nil {
return false, fmt.Errorf("error creating VIPService: %w", err)
}
@@ -338,31 +342,48 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
return false, fmt.Errorf("failed to update tailscaled config: %w", err)
}
// TODO(irbekrm): check that the replicas are ready to route traffic for the VIPService before updating Ingress
// status.
// 6. Update Ingress status
// 6. Update Ingress status if ProxyGroup Pods are ready.
count, err := r.numberPodsAdvertising(ctx, pg.Name, serviceName)
if err != nil {
return false, fmt.Errorf("failed to check if any Pods are configured: %w", err)
}
oldStatus := ing.Status.DeepCopy()
ports := []networkingv1.IngressPortStatus{
{
Protocol: "TCP",
Port: 443,
},
switch count {
case 0:
ing.Status.LoadBalancer.Ingress = nil
default:
ports := []networkingv1.IngressPortStatus{
{
Protocol: "TCP",
Port: 443,
},
}
if isHTTPEndpointEnabled(ing) {
ports = append(ports, networkingv1.IngressPortStatus{
Protocol: "TCP",
Port: 80,
})
}
ing.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{
{
Hostname: dnsName,
Ports: ports,
},
}
}
if isHTTPEndpointEnabled(ing) {
ports = append(ports, networkingv1.IngressPortStatus{
Protocol: "TCP",
Port: 80,
})
}
ing.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{
{
Hostname: dnsName,
Ports: ports,
},
}
if apiequality.Semantic.DeepEqual(oldStatus, ing.Status) {
if apiequality.Semantic.DeepEqual(oldStatus, &ing.Status) {
return svcsChanged, nil
}
const prefix = "Updating Ingress status"
if count == 0 {
logger.Infof("%s. No Pods are advertising VIPService yet", prefix)
} else {
logger.Infof("%s. %d Pod(s) advertising VIPService", prefix, count)
}
if err := r.Status().Update(ctx, ing); err != nil {
return false, fmt.Errorf("failed to update Ingress status: %w", err)
}
@@ -402,24 +423,24 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
logger.Infof("VIPService %q is not owned by any Ingress, cleaning up", vipServiceName)
// Delete the VIPService from control if necessary.
svc, _ := r.tsClient.GetVIPService(ctx, vipServiceName)
if svc != nil && isVIPServiceForAnyIngress(svc) {
logger.Infof("cleaning up orphaned VIPService %q", vipServiceName)
svcsChanged, err = r.cleanupVIPService(ctx, vipServiceName, logger)
if err != nil {
errResp := &tailscale.ErrResponse{}
if !errors.As(err, &errResp) || errResp.Status != http.StatusNotFound {
return false, fmt.Errorf("deleting VIPService %q: %w", vipServiceName, err)
}
}
svcsChanged, err = r.cleanupVIPService(ctx, vipServiceName, logger)
if err != nil {
return false, fmt.Errorf("deleting VIPService %q: %w", vipServiceName, err)
}
// Make sure the VIPService is not advertised in tailscaled or serve config.
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, proxyGroupName, vipServiceName, false, logger); err != nil {
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
delete(cfg.Services, vipServiceName)
serveConfigChanged = true
_, ok := cfg.Services[vipServiceName]
if ok {
logger.Infof("Removing VIPService %q from serve config", vipServiceName)
delete(cfg.Services, vipServiceName)
serveConfigChanged = true
}
if err := r.cleanupCertResources(ctx, proxyGroupName, vipServiceName); err != nil {
return false, fmt.Errorf("failed to clean up cert resources: %w", err)
}
}
}
@@ -480,16 +501,22 @@ func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string,
if err != nil {
return false, fmt.Errorf("error deleting VIPService: %w", err)
}
// 3. Clean up any cluster resources
if err := r.cleanupCertResources(ctx, pg, serviceName); err != nil {
return false, fmt.Errorf("failed to clean up cert resources: %w", err)
}
if cfg == nil || cfg.Services == nil { // user probably deleted the ProxyGroup
return svcChanged, nil
}
// 3. Unadvertise the VIPService in tailscaled config.
// 4. Unadvertise the VIPService in tailscaled config.
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, pg, serviceName, false, logger); err != nil {
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
// 4. Remove the VIPService from the serve config for the ProxyGroup.
// 5. Remove the VIPService from the serve config for the ProxyGroup.
logger.Infof("Removing VIPService %q from serve config for ProxyGroup %q", hostname, pg)
delete(cfg.Services, serviceName)
cfgBytes, err := json.Marshal(cfg)
@@ -570,13 +597,6 @@ func (r *HAIngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
return isTSIngress && pgAnnot != ""
}
func isVIPServiceForAnyIngress(svc *tailscale.VIPService) bool {
if svc == nil {
return false
}
return strings.HasPrefix(svc.Comment, "tailscale.com/k8s-operator:owned-by:")
}
// validateIngress validates that the Ingress is properly configured.
// Currently validates:
// - Any tags provided via tailscale.com/tags annotation are valid Tailscale ACL tags
@@ -650,34 +670,34 @@ func (r *HAIngressReconciler) cleanupVIPService(ctx context.Context, name tailcf
if svc == nil {
return false, nil
}
c, err := parseComment(svc)
o, err := parseOwnerAnnotation(svc)
if err != nil {
return false, fmt.Errorf("error parsing VIPService comment")
return false, fmt.Errorf("error parsing VIPService owner annotation")
}
if c == nil || len(c.OwnerRefs) == 0 {
if o == nil || len(o.OwnerRefs) == 0 {
return false, nil
}
// Comparing with the operatorID only means that we will not be able to
// clean up VIPServices in cases where the operator was deleted from the
// cluster before deleting the Ingress. Perhaps the comparison could be
// 'if or.OperatorID === r.operatorID || or.ingressUID == r.ingressUID'.
ix := slices.IndexFunc(c.OwnerRefs, func(or OwnerRef) bool {
ix := slices.IndexFunc(o.OwnerRefs, func(or OwnerRef) bool {
return or.OperatorID == r.operatorID
})
if ix == -1 {
return false, nil
}
if len(c.OwnerRefs) == 1 {
if len(o.OwnerRefs) == 1 {
logger.Infof("Deleting VIPService %q", name)
return false, r.tsClient.DeleteVIPService(ctx, name)
}
c.OwnerRefs = slices.Delete(c.OwnerRefs, ix, ix+1)
o.OwnerRefs = slices.Delete(o.OwnerRefs, ix, ix+1)
logger.Infof("Deleting VIPService %q", name)
json, err := json.Marshal(c)
json, err := json.Marshal(o)
if err != nil {
return false, fmt.Errorf("error marshalling updated VIPService owner reference: %w", err)
}
svc.Comment = string(json)
svc.Annotations[ownerAnnotation] = string(json)
return true, r.tsClient.CreateOrUpdateVIPService(ctx, svc)
}
@@ -740,6 +760,39 @@ func (a *HAIngressReconciler) maybeUpdateAdvertiseServicesConfig(ctx context.Con
return nil
}
func (a *HAIngressReconciler) numberPodsAdvertising(ctx context.Context, pgName string, serviceName tailcfg.ServiceName) (int, error) {
// Get all state Secrets for this ProxyGroup.
secrets := &corev1.SecretList{}
if err := a.List(ctx, secrets, client.InNamespace(a.tsNamespace), client.MatchingLabels(pgSecretLabels(pgName, "state"))); err != nil {
return 0, fmt.Errorf("failed to list ProxyGroup %q state Secrets: %w", pgName, err)
}
var count int
for _, secret := range secrets.Items {
prefs, ok, err := getDevicePrefs(&secret)
if err != nil {
return 0, fmt.Errorf("error getting node metadata: %w", err)
}
if !ok {
continue
}
if slices.Contains(prefs.AdvertiseServices, serviceName.String()) {
count++
}
}
return count, nil
}
const ownerAnnotation = "tailscale.com/owner-references"
// ownerAnnotationValue is the content of the VIPService.Annotation[ownerAnnotation] field.
type ownerAnnotationValue struct {
// OwnerRefs is a list of owner references that identify all operator
// instances that manage this VIPService.
OwnerRefs []OwnerRef `json:"ownerRefs,omitempty"`
}
// OwnerRef is an owner reference that uniquely identifies a Tailscale
// Kubernetes operator instance.
type OwnerRef struct {
@@ -747,60 +800,110 @@ type OwnerRef struct {
OperatorID string `json:"operatorID,omitempty"`
}
// comment is the content of the VIPService.Comment field.
type comment struct {
// OwnerRefs is a list of owner references that identify all operator
// instances that manage this VIPService.
OwnerRefs []OwnerRef `json:"ownerRefs,omitempty"`
}
// ownerRefsComment return VIPService Comment that includes owner reference for this
// operator instance for the provided VIPService. If the VIPService is nil, a
// new comment with owner ref is returned. If the VIPService is not nil, the
// existing comment is returned with the owner reference added, if not already
// present. If the VIPService is not nil, but does not contain a comment we
// return an error as this likely means that the VIPService was created by
// somthing other than a Tailscale Kubernetes operator.
func (r *HAIngressReconciler) ownerRefsComment(svc *tailscale.VIPService) (string, error) {
// ownerAnnotations returns the updated annotations required to ensure this
// instance of the operator is included as an owner. If the VIPService is not
// nil, but does not contain an owner we return an error as this likely means
// that the VIPService was created by somthing other than a Tailscale
// Kubernetes operator.
func (r *HAIngressReconciler) ownerAnnotations(svc *tailscale.VIPService) (map[string]string, error) {
ref := OwnerRef{
OperatorID: r.operatorID,
}
if svc == nil {
c := &comment{OwnerRefs: []OwnerRef{ref}}
c := ownerAnnotationValue{OwnerRefs: []OwnerRef{ref}}
json, err := json.Marshal(c)
if err != nil {
return "", fmt.Errorf("[unexpected] unable to marshal VIPService comment contents: %w, please report this", err)
return nil, fmt.Errorf("[unexpected] unable to marshal VIPService owner annotation contents: %w, please report this", err)
}
return string(json), nil
return map[string]string{
ownerAnnotation: string(json),
}, nil
}
c, err := parseComment(svc)
o, err := parseOwnerAnnotation(svc)
if err != nil {
return "", fmt.Errorf("error parsing existing VIPService comment: %w", err)
return nil, err
}
if c == nil || len(c.OwnerRefs) == 0 {
return "", fmt.Errorf("VIPService %s exists, but does not contain Comment field with owner references- not proceeding as this is likely a resource created by something other than a Tailscale Kubernetes Operator", svc.Name)
if o == nil || len(o.OwnerRefs) == 0 {
return nil, fmt.Errorf("VIPService %s exists, but does not contain owner annotation with owner references; not proceeding as this is likely a resource created by something other than the Tailscale Kubernetes operator", svc.Name)
}
if slices.Contains(c.OwnerRefs, ref) { // up to date
return svc.Comment, nil
if slices.Contains(o.OwnerRefs, ref) { // up to date
return svc.Annotations, nil
}
c.OwnerRefs = append(c.OwnerRefs, ref)
json, err := json.Marshal(c)
o.OwnerRefs = append(o.OwnerRefs, ref)
json, err := json.Marshal(o)
if err != nil {
return "", fmt.Errorf("error marshalling updated owner references: %w", err)
return nil, fmt.Errorf("error marshalling updated owner references: %w", err)
}
return string(json), nil
newAnnots := make(map[string]string, len(svc.Annotations)+1)
for k, v := range svc.Annotations {
newAnnots[k] = v
}
newAnnots[ownerAnnotation] = string(json)
return newAnnots, nil
}
// parseComment returns VIPService comment or nil if none found or not matching the expected format.
func parseComment(vipSvc *tailscale.VIPService) (*comment, error) {
if vipSvc.Comment == "" {
// parseOwnerAnnotation returns nil if no valid owner found.
func parseOwnerAnnotation(vipSvc *tailscale.VIPService) (*ownerAnnotationValue, error) {
if vipSvc.Annotations == nil || vipSvc.Annotations[ownerAnnotation] == "" {
return nil, nil
}
c := &comment{}
if err := json.Unmarshal([]byte(vipSvc.Comment), c); err != nil {
return nil, fmt.Errorf("error parsing VIPService Comment field %q: %w", vipSvc.Comment, err)
o := &ownerAnnotationValue{}
if err := json.Unmarshal([]byte(vipSvc.Annotations[ownerAnnotation]), o); err != nil {
return nil, fmt.Errorf("error parsing VIPService %s annotation %q: %w", ownerAnnotation, vipSvc.Annotations[ownerAnnotation], err)
}
return c, nil
return o, nil
}
func ownersAreSetAndEqual(a, b *tailscale.VIPService) bool {
return a != nil && b != nil &&
a.Annotations != nil && b.Annotations != nil &&
a.Annotations[ownerAnnotation] != "" &&
b.Annotations[ownerAnnotation] != "" &&
strings.EqualFold(a.Annotations[ownerAnnotation], b.Annotations[ownerAnnotation])
}
// ensureCertResources ensures that the TLS Secret for an HA Ingress and RBAC
// resources that allow proxies to manage the Secret are created.
// Note that Tailscale VIPService name validation matches Kubernetes
// resource name validation, so we can be certain that the VIPService name
// (domain) is a valid Kubernetes resource name.
// https://github.com/tailscale/tailscale/blob/8b1e7f646ee4730ad06c9b70c13e7861b964949b/util/dnsname/dnsname.go#L99
// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names
func (r *HAIngressReconciler) ensureCertResources(ctx context.Context, pgName, domain string) error {
secret := certSecret(pgName, r.tsNamespace, domain)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, secret, nil); err != nil {
return fmt.Errorf("failed to create or update Secret %s: %w", secret.Name, err)
}
role := certSecretRole(pgName, r.tsNamespace, domain)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, nil); err != nil {
return fmt.Errorf("failed to create or update Role %s: %w", role.Name, err)
}
rb := certSecretRoleBinding(pgName, r.tsNamespace, domain)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, rb, nil); err != nil {
return fmt.Errorf("failed to create or update RoleBinding %s: %w", rb.Name, err)
}
return nil
}
// cleanupCertResources ensures that the TLS Secret and associated RBAC
// resources that allow proxies to read/write to the Secret are deleted.
func (r *HAIngressReconciler) cleanupCertResources(ctx context.Context, pgName string, name tailcfg.ServiceName) error {
domainName, err := r.dnsNameForService(ctx, tailcfg.ServiceName(name))
if err != nil {
return fmt.Errorf("error getting DNS name for VIPService %s: %w", name, err)
}
labels := certResourceLabels(pgName, domainName)
if err := r.DeleteAllOf(ctx, &rbacv1.RoleBinding{}, client.InNamespace(r.tsNamespace), client.MatchingLabels(labels)); err != nil {
return fmt.Errorf("error deleting RoleBinding for domain name %s: %w", domainName, err)
}
if err := r.DeleteAllOf(ctx, &rbacv1.Role{}, client.InNamespace(r.tsNamespace), client.MatchingLabels(labels)); err != nil {
return fmt.Errorf("error deleting Role for domain name %s: %w", domainName, err)
}
if err := r.DeleteAllOf(ctx, &corev1.Secret{}, client.InNamespace(r.tsNamespace), client.MatchingLabels(labels)); err != nil {
return fmt.Errorf("error deleting Secret for domain name %s: %w", domainName, err)
}
return nil
}
// requeueInterval returns a time duration between 5 and 10 minutes, which is
@@ -811,3 +914,93 @@ func parseComment(vipSvc *tailscale.VIPService) (*comment, error) {
func requeueInterval() time.Duration {
return time.Duration(rand.N(5)+5) * time.Minute
}
// certSecretRole creates a Role that will allow proxies to manage the TLS
// Secret for the given domain. Domain must be a valid Kubernetes resource name.
func certSecretRole(pgName, namespace, domain string) *rbacv1.Role {
return &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: domain,
Namespace: namespace,
Labels: certResourceLabels(pgName, domain),
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Resources: []string{"secrets"},
ResourceNames: []string{domain},
Verbs: []string{
"get",
"list",
"patch",
"update",
},
},
},
}
}
// certSecretRoleBinding creates a RoleBinding for Role that will allow proxies
// to manage the TLS Secret for the given domain. Domain must be a valid
// Kubernetes resource name.
func certSecretRoleBinding(pgName, namespace, domain string) *rbacv1.RoleBinding {
return &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: domain,
Namespace: namespace,
Labels: certResourceLabels(pgName, domain),
},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: pgName,
Namespace: namespace,
},
},
RoleRef: rbacv1.RoleRef{
Kind: "Role",
Name: domain,
},
}
}
// certSecret creates a Secret that will store the TLS certificate and private
// key for the given domain. Domain must be a valid Kubernetes resource name.
func certSecret(pgName, namespace, domain string) *corev1.Secret {
labels := certResourceLabels(pgName, domain)
labels[kubetypes.LabelSecretType] = "certs"
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Name: domain,
Namespace: namespace,
Labels: labels,
},
Data: map[string][]byte{
corev1.TLSCertKey: nil,
corev1.TLSPrivateKeyKey: nil,
},
Type: corev1.SecretTypeTLS,
}
}
func certResourceLabels(pgName, domain string) map[string]string {
return map[string]string{
kubetypes.LabelManaged: "true",
"tailscale.com/proxy-group": pgName,
"tailscale.com/domain": domain,
}
}
// dnsNameForService returns the DNS name for the given VIPService name.
func (r *HAIngressReconciler) dnsNameForService(ctx context.Context, svc tailcfg.ServiceName) (string, error) {
s := svc.WithoutPrefix()
tcd, err := r.tailnetCertDomain(ctx)
if err != nil {
return "", fmt.Errorf("error determining DNS name base: %w", err)
}
return s + "." + tcd, nil
}

View File

@@ -8,8 +8,10 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"reflect"
"testing"
@@ -18,6 +20,7 @@ import (
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
@@ -68,6 +71,11 @@ func TestIngressPGReconciler(t *testing.T) {
verifyVIPService(t, ft, "svc:my-svc", []string{"443"})
verifyTailscaledConfig(t, fc, []string{"svc:my-svc"})
// Verify cert resources were created for the first Ingress
expectEqual(t, fc, certSecret("test-pg", "operator-ns", "my-svc.ts.net"))
expectEqual(t, fc, certSecretRole("test-pg", "operator-ns", "my-svc.ts.net"))
expectEqual(t, fc, certSecretRoleBinding("test-pg", "operator-ns", "my-svc.ts.net"))
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
ing.Annotations["tailscale.com/tags"] = "tag:custom,tag:test"
})
@@ -122,6 +130,11 @@ func TestIngressPGReconciler(t *testing.T) {
verifyServeConfig(t, fc, "svc:my-other-svc", false)
verifyVIPService(t, ft, "svc:my-other-svc", []string{"443"})
// Verify cert resources were created for the second Ingress
expectEqual(t, fc, certSecret("test-pg", "operator-ns", "my-other-svc.ts.net"))
expectEqual(t, fc, certSecretRole("test-pg", "operator-ns", "my-other-svc.ts.net"))
expectEqual(t, fc, certSecretRoleBinding("test-pg", "operator-ns", "my-other-svc.ts.net"))
// Verify first Ingress is still working
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyVIPService(t, ft, "svc:my-svc", []string{"443"})
@@ -158,6 +171,9 @@ func TestIngressPGReconciler(t *testing.T) {
}
verifyTailscaledConfig(t, fc, []string{"svc:my-svc"})
expectMissing[corev1.Secret](t, fc, "operator-ns", "my-other-svc.ts.net")
expectMissing[rbacv1.Role](t, fc, "operator-ns", "my-other-svc.ts.net")
expectMissing[rbacv1.RoleBinding](t, fc, "operator-ns", "my-other-svc.ts.net")
// Delete the first Ingress and verify cleanup
if err := fc.Delete(context.Background(), ing); err != nil {
@@ -184,6 +200,66 @@ func TestIngressPGReconciler(t *testing.T) {
t.Error("serve config not cleaned up")
}
verifyTailscaledConfig(t, fc, nil)
// Add verification that cert resources were cleaned up
expectMissing[corev1.Secret](t, fc, "operator-ns", "my-svc.ts.net")
expectMissing[rbacv1.Role](t, fc, "operator-ns", "my-svc.ts.net")
expectMissing[rbacv1.RoleBinding](t, fc, "operator-ns", "my-svc.ts.net")
}
func TestIngressPGReconciler_UpdateIngressHostname(t *testing.T) {
ingPGR, fc, ft := setupIngressTest(t)
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/proxy-group": "test-pg",
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"my-svc.tailnetxyz.ts.net"}},
},
},
}
mustCreate(t, fc, ing)
// Verify initial reconciliation
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyVIPService(t, ft, "svc:my-svc", []string{"443"})
verifyTailscaledConfig(t, fc, []string{"svc:my-svc"})
// Update the Ingress hostname and make sure the original VIPService is deleted.
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
ing.Spec.TLS[0].Hosts[0] = "updated-svc.tailnetxyz.ts.net"
})
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyServeConfig(t, fc, "svc:updated-svc", false)
verifyVIPService(t, ft, "svc:updated-svc", []string{"443"})
verifyTailscaledConfig(t, fc, []string{"svc:updated-svc"})
_, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName("svc:my-svc"))
if err == nil {
t.Fatalf("svc:my-svc not cleaned up")
}
var errResp *tailscale.ErrResponse
if !errors.As(err, &errResp) || errResp.Status != http.StatusNotFound {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateIngress(t *testing.T) {
@@ -404,6 +480,31 @@ func TestIngressPGReconciler_HTTPEndpoint(t *testing.T) {
t.Fatal(err)
}
// Status will be empty until the VIPService shows up in prefs.
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress, []networkingv1.IngressLoadBalancerIngress(nil)) {
t.Errorf("incorrect Ingress status: got %v, want empty",
ing.Status.LoadBalancer.Ingress)
}
// Add the VIPService to prefs to have the Ingress recognised as ready.
mustCreate(t, fc, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg-0",
Namespace: "operator-ns",
Labels: pgSecretLabels("test-pg", "state"),
},
Data: map[string][]byte{
"_current-profile": []byte("profile-foo"),
"profile-foo": []byte(`{"AdvertiseServices":["svc:my-svc"],"Config":{"NodeID":"node-foo"}}`),
},
})
// Reconcile and re-fetch Ingress.
expectReconciled(t, ingPGR, "default", "test-ingress")
if err := fc.Get(context.Background(), client.ObjectKeyFromObject(ing), ing); err != nil {
t.Fatal(err)
}
wantStatus := []networkingv1.IngressPortStatus{
{Port: 443, Protocol: "TCP"},
{Port: 80, Protocol: "TCP"},
@@ -644,8 +745,10 @@ func TestIngressPGReconciler_MultiCluster(t *testing.T) {
// Simulate existing VIPService from another cluster
existingVIPSvc := &tailscale.VIPService{
Name: "svc:my-svc",
Comment: `{"ownerrefs":[{"operatorID":"operator-2"}]}`,
Name: "svc:my-svc",
Annotations: map[string]string{
ownerAnnotation: `{"ownerrefs":[{"operatorID":"operator-2"}]}`,
},
}
ft.vipServices = map[tailcfg.ServiceName]*tailscale.VIPService{
"svc:my-svc": existingVIPSvc,
@@ -662,17 +765,17 @@ func TestIngressPGReconciler_MultiCluster(t *testing.T) {
t.Fatal("VIPService not found")
}
c := &comment{}
if err := json.Unmarshal([]byte(vipSvc.Comment), c); err != nil {
t.Fatalf("parsing comment: %v", err)
o, err := parseOwnerAnnotation(vipSvc)
if err != nil {
t.Fatalf("parsing owner annotation: %v", err)
}
wantOwnerRefs := []OwnerRef{
{OperatorID: "operator-2"},
{OperatorID: "operator-1"},
}
if !reflect.DeepEqual(c.OwnerRefs, wantOwnerRefs) {
t.Errorf("incorrect owner refs\ngot: %+v\nwant: %+v", c.OwnerRefs, wantOwnerRefs)
if !reflect.DeepEqual(o.OwnerRefs, wantOwnerRefs) {
t.Errorf("incorrect owner refs\ngot: %+v\nwant: %+v", o.OwnerRefs, wantOwnerRefs)
}
// Delete the Ingress and verify VIPService still exists with one owner ref
@@ -689,15 +792,15 @@ func TestIngressPGReconciler_MultiCluster(t *testing.T) {
t.Fatal("VIPService was incorrectly deleted")
}
c = &comment{}
if err := json.Unmarshal([]byte(vipSvc.Comment), c); err != nil {
t.Fatalf("parsing comment after deletion: %v", err)
o, err = parseOwnerAnnotation(vipSvc)
if err != nil {
t.Fatalf("parsing owner annotation: %v", err)
}
wantOwnerRefs = []OwnerRef{
{OperatorID: "operator-2"},
}
if !reflect.DeepEqual(c.OwnerRefs, wantOwnerRefs) {
t.Errorf("incorrect owner refs after deletion\ngot: %+v\nwant: %+v", c.OwnerRefs, wantOwnerRefs)
if !reflect.DeepEqual(o.OwnerRefs, wantOwnerRefs) {
t.Errorf("incorrect owner refs after deletion\ngot: %+v\nwant: %+v", o.OwnerRefs, wantOwnerRefs)
}
}

View File

@@ -6,6 +6,7 @@
package main
import (
"context"
"testing"
"go.uber.org/zap"
@@ -15,17 +16,18 @@ import (
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"tailscale.com/ipn"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstest"
"tailscale.com/types/ptr"
"tailscale.com/util/mak"
)
func TestTailscaleIngress(t *testing.T) {
tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}}
fc := fake.NewFakeClient(tsIngressClass)
fc := fake.NewFakeClient(ingressClass())
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
@@ -46,45 +48,8 @@ func TestTailscaleIngress(t *testing.T) {
}
// 1. Resources get created for regular Ingress
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"default-test"}},
},
},
}
mustCreate(t, fc, ing)
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "1.2.3.4",
Ports: []corev1.ServicePort{{
Port: 8080,
Name: "http"},
},
},
})
mustCreate(t, fc, ingress())
mustCreate(t, fc, service())
expectReconciled(t, ingR, "default", "test")
@@ -114,6 +79,9 @@ func TestTailscaleIngress(t *testing.T) {
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
})
expectReconciled(t, ingR, "default", "test")
// Get the ingress and update it with expected changes
ing := ingress()
ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer")
ing.Status.LoadBalancer = networkingv1.IngressLoadBalancerStatus{
Ingress: []networkingv1.IngressLoadBalancerIngress{
@@ -143,8 +111,7 @@ func TestTailscaleIngress(t *testing.T) {
}
func TestTailscaleIngressHostname(t *testing.T) {
tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}}
fc := fake.NewFakeClient(tsIngressClass)
fc := fake.NewFakeClient(ingressClass())
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
@@ -165,45 +132,8 @@ func TestTailscaleIngressHostname(t *testing.T) {
}
// 1. Resources get created for regular Ingress
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"default-test"}},
},
},
}
mustCreate(t, fc, ing)
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "1.2.3.4",
Ports: []corev1.ServicePort{{
Port: 8080,
Name: "http"},
},
},
})
mustCreate(t, fc, ingress())
mustCreate(t, fc, service())
expectReconciled(t, ingR, "default", "test")
@@ -241,8 +171,10 @@ func TestTailscaleIngressHostname(t *testing.T) {
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
})
expectReconciled(t, ingR, "default", "test")
ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer")
// Get the ingress and update it with expected changes
ing := ingress()
ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer")
expectEqual(t, fc, ing)
// 3. Ingress proxy with capability version >= 110 advertises HTTPS endpoint
@@ -299,10 +231,9 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
Annotations: map[string]string{"bar.io/foo": "some-val"},
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
}
tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pc, tsIngressClass).
WithObjects(pc, ingressClass()).
WithStatusSubresource(pc).
Build()
ft := &fakeTSClient{}
@@ -326,45 +257,8 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
// 1. Ingress is created with no ProxyClass specified, default proxy
// resources get configured.
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"default-test"}},
},
},
}
mustCreate(t, fc, ing)
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "1.2.3.4",
Ports: []corev1.ServicePort{{
Port: 8080,
Name: "http"},
},
},
})
mustCreate(t, fc, ingress())
mustCreate(t, fc, service())
expectReconciled(t, ingR, "default", "test")
@@ -432,54 +326,19 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
ObservedGeneration: 1,
}}},
}
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
Labels: map[string]string{
"tailscale.com/proxy-class": "metrics",
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"default-test"}},
},
},
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "1.2.3.4",
Ports: []corev1.ServicePort{{
Port: 8080,
Name: "http"},
},
},
}
crd := &apiextensionsv1.CustomResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: serviceMonitorCRD}}
tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}}
// Create fake client with ProxyClass, IngressClass, Ingress with metrics ProxyClass, and Service
ing := ingress()
ing.Labels = map[string]string{
LabelProxyClass: "metrics",
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pc, tsIngressClass, ing, svc).
WithObjects(pc, ingressClass(), ing, service()).
WithStatusSubresource(pc).
Build()
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
@@ -560,3 +419,118 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
expectMissing[corev1.Service](t, fc, "operator-ns", metricsResourceName(shortName))
// ServiceMonitor gets garbage collected when the Service is deleted - we cannot test that here.
}
func TestIngressLetsEncryptStaging(t *testing.T) {
cl := tstest.NewClock(tstest.ClockOpts{})
zl := zap.Must(zap.NewDevelopment())
pcLEStaging, pcLEStagingFalse, pcOther := proxyClassesForLEStagingTest()
testCases := testCasesForLEStagingTests(pcLEStaging, pcLEStagingFalse, pcOther)
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
builder := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme)
builder = builder.WithObjects(pcLEStaging, pcLEStagingFalse, pcOther).
WithStatusSubresource(pcLEStaging, pcLEStagingFalse, pcOther)
fc := builder.Build()
if tt.proxyClassPerResource != "" || tt.defaultProxyClass != "" {
name := tt.proxyClassPerResource
if name == "" {
name = tt.defaultProxyClass
}
setProxyClassReady(t, fc, cl, name)
}
mustCreate(t, fc, ingressClass())
mustCreate(t, fc, service())
ing := ingress()
if tt.proxyClassPerResource != "" {
ing.Labels = map[string]string{
LabelProxyClass: tt.proxyClassPerResource,
}
}
mustCreate(t, fc, ing)
ingR := &IngressReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: &fakeTSClient{},
tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}},
defaultTags: []string{"tag:test"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale:test",
},
logger: zl.Sugar(),
defaultProxyClass: tt.defaultProxyClass,
}
expectReconciled(t, ingR, "default", "test")
_, shortName := findGenName(t, fc, "default", "test", "ingress")
sts := &appsv1.StatefulSet{}
if err := fc.Get(context.Background(), client.ObjectKey{Namespace: "operator-ns", Name: shortName}, sts); err != nil {
t.Fatalf("failed to get StatefulSet: %v", err)
}
if tt.useLEStagingEndpoint {
verifyEnvVar(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL", letsEncryptStagingEndpoint)
} else {
verifyEnvVarNotPresent(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL")
}
})
}
}
func ingressClass() *networkingv1.IngressClass {
return &networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
}
}
func service() *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "1.2.3.4",
Ports: []corev1.ServicePort{{
Port: 8080,
Name: "http"},
},
},
}
}
func ingress() *networkingv1.Ingress {
return &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
UID: types.UID("1234-UID"),
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"default-test"}},
},
},
}
}

View File

@@ -19,6 +19,7 @@ import (
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
)
const (
@@ -222,7 +223,7 @@ func metricsResourceName(stsName string) string {
// proxy.
func metricsResourceLabels(opts *metricsOpts) map[string]string {
lbls := map[string]string{
LabelManaged: "true",
kubetypes.LabelManaged: "true",
labelMetricsTarget: opts.proxyStsName,
labelPromProxyType: opts.proxyType,
labelPromProxyParentName: opts.proxyLabels[LabelParentName],

View File

@@ -347,6 +347,7 @@ func runReconcilers(opts reconcilerOpts) {
For(&networkingv1.Ingress{}).
Named("ingress-pg-reconciler").
Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngressPG(mgr.GetClient(), startlog))).
Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(ingressesFromPGStateSecret(mgr.GetClient(), startlog))).
Watches(&tsapi.ProxyGroup{}, ingressProxyGroupFilter).
Complete(&HAIngressReconciler{
recorder: eventRecorder,
@@ -636,8 +637,8 @@ func enqueueAllIngressEgressProxySvcsInNS(ns string, cl client.Client, logger *z
// Get all headless Services for proxies configured using Service.
svcProxyLabels := map[string]string{
LabelManaged: "true",
LabelParentType: "svc",
kubetypes.LabelManaged: "true",
LabelParentType: "svc",
}
svcHeadlessSvcList := &corev1.ServiceList{}
if err := cl.List(ctx, svcHeadlessSvcList, client.InNamespace(ns), client.MatchingLabels(svcProxyLabels)); err != nil {
@@ -650,8 +651,8 @@ func enqueueAllIngressEgressProxySvcsInNS(ns string, cl client.Client, logger *z
// Get all headless Services for proxies configured using Ingress.
ingProxyLabels := map[string]string{
LabelManaged: "true",
LabelParentType: "ingress",
kubetypes.LabelManaged: "true",
LabelParentType: "ingress",
}
ingHeadlessSvcList := &corev1.ServiceList{}
if err := cl.List(ctx, ingHeadlessSvcList, client.InNamespace(ns), client.MatchingLabels(ingProxyLabels)); err != nil {
@@ -718,7 +719,7 @@ func dnsRecordsReconcilerIngressHandler(ns string, isDefaultLoadBalancer bool, c
func isManagedResource(o client.Object) bool {
ls := o.GetLabels()
return ls[LabelManaged] == "true"
return ls[kubetypes.LabelManaged] == "true"
}
func isManagedByType(o client.Object, typ string) bool {
@@ -955,7 +956,7 @@ func egressPodsHandler(_ context.Context, o client.Object) []reconcile.Request {
// returns reconciler requests for all egress EndpointSlices for that ProxyGroup.
func egressEpsFromPGPods(cl client.Client, ns string) handler.MapFunc {
return func(_ context.Context, o client.Object) []reconcile.Request {
if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
if v, ok := o.GetLabels()[kubetypes.LabelManaged]; !ok || v != "true" {
return nil
}
// TODO(irbekrm): for now this is good enough as all ProxyGroups are egress. Add a type check once we
@@ -975,15 +976,13 @@ func egressEpsFromPGPods(cl client.Client, ns string) handler.MapFunc {
// returns reconciler requests for all egress EndpointSlices for that ProxyGroup.
func egressEpsFromPGStateSecrets(cl client.Client, ns string) handler.MapFunc {
return func(_ context.Context, o client.Object) []reconcile.Request {
if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
if v, ok := o.GetLabels()[kubetypes.LabelManaged]; !ok || v != "true" {
return nil
}
// TODO(irbekrm): for now this is good enough as all ProxyGroups are egress. Add a type check once we
// have ingress ProxyGroups.
if parentType := o.GetLabels()[LabelParentType]; parentType != "proxygroup" {
return nil
}
if secretType := o.GetLabels()[labelSecretType]; secretType != "state" {
if secretType := o.GetLabels()[kubetypes.LabelSecretType]; secretType != "state" {
return nil
}
pg, ok := o.GetLabels()[LabelParentName]
@@ -1000,7 +999,7 @@ func egressSvcFromEps(_ context.Context, o client.Object) []reconcile.Request {
if typ := o.GetLabels()[labelSvcType]; typ != typeEgress {
return nil
}
if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
if v, ok := o.GetLabels()[kubetypes.LabelManaged]; !ok || v != "true" {
return nil
}
svcName, ok := o.GetLabels()[LabelParentName]
@@ -1040,6 +1039,45 @@ func reconcileRequestsForPG(pg string, cl client.Client, ns string) []reconcile.
return reqs
}
func ingressesFromPGStateSecret(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
return func(ctx context.Context, o client.Object) []reconcile.Request {
secret, ok := o.(*corev1.Secret)
if !ok {
logger.Infof("[unexpected] ProxyGroup handler triggered for an object that is not a ProxyGroup")
return nil
}
if secret.ObjectMeta.Labels[kubetypes.LabelManaged] != "true" {
return nil
}
if secret.ObjectMeta.Labels[LabelParentType] != "proxygroup" {
return nil
}
if secret.ObjectMeta.Labels[kubetypes.LabelSecretType] != "state" {
return nil
}
pgName, ok := secret.ObjectMeta.Labels[LabelParentName]
if !ok {
return nil
}
ingList := &networkingv1.IngressList{}
if err := cl.List(ctx, ingList, client.MatchingFields{indexIngressProxyGroup: pgName}); err != nil {
logger.Infof("error listing Ingresses, skipping a reconcile for event on Secret %s: %v", secret.Name, err)
return nil
}
reqs := make([]reconcile.Request, 0)
for _, ing := range ingList.Items {
reqs = append(reqs, reconcile.Request{
NamespacedName: types.NamespacedName{
Namespace: ing.Namespace,
Name: ing.Name,
},
})
}
return reqs
}
}
// egressSvcsFromEgressProxyGroup is an event handler for egress ProxyGroups. It returns reconcile requests for all
// user-created ExternalName Services that should be exposed on this ProxyGroup.
func egressSvcsFromEgressProxyGroup(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
@@ -1145,9 +1183,9 @@ func podsFromEgressEps(cl client.Client, logger *zap.SugaredLogger, ns string) h
return nil
}
podLabels := map[string]string{
LabelManaged: "true",
LabelParentType: "proxygroup",
LabelParentName: eps.Labels[labelProxyGroup],
kubetypes.LabelManaged: "true",
LabelParentType: "proxygroup",
LabelParentName: eps.Labels[labelProxyGroup],
}
podList := &corev1.PodList{}
if err := cl.List(ctx, podList, client.InNamespace(ns),

View File

@@ -1387,10 +1387,10 @@ func Test_serviceHandlerForIngress(t *testing.T) {
Name: "headless-1",
Namespace: "tailscale",
Labels: map[string]string{
LabelManaged: "true",
LabelParentName: "ing-1",
LabelParentNamespace: "ns-1",
LabelParentType: "ingress",
kubetypes.LabelManaged: "true",
LabelParentName: "ing-1",
LabelParentNamespace: "ns-1",
LabelParentType: "ingress",
},
},
}

View File

@@ -302,7 +302,10 @@ func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, pg *tsapi.Pro
if err != nil {
return fmt.Errorf("error generating StatefulSet spec: %w", err)
}
ss = applyProxyClassToStatefulSet(proxyClass, ss, nil, logger)
cfg := &tailscaleSTSConfig{
proxyType: string(pg.Spec.Type),
}
ss = applyProxyClassToStatefulSet(proxyClass, ss, cfg, logger)
capver, err := r.capVerForPG(ctx, pg, logger)
if err != nil {
return fmt.Errorf("error getting device info: %w", err)
@@ -461,7 +464,7 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(ctx context.Context, p
var existingCfgSecret *corev1.Secret // unmodified copy of secret
if err := r.Get(ctx, client.ObjectKeyFromObject(cfgSecret), cfgSecret); err == nil {
logger.Debugf("secret %s/%s already exists", cfgSecret.GetNamespace(), cfgSecret.GetName())
logger.Debugf("Secret %s/%s already exists", cfgSecret.GetNamespace(), cfgSecret.GetName())
existingCfgSecret = cfgSecret.DeepCopy()
} else if !apierrors.IsNotFound(err) {
return "", err
@@ -469,7 +472,7 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(ctx context.Context, p
var authKey string
if existingCfgSecret == nil {
logger.Debugf("creating authkey for new ProxyGroup proxy")
logger.Debugf("Creating authkey for new ProxyGroup proxy")
tags := pg.Spec.Tags.Stringify()
if len(tags) == 0 {
tags = r.defaultTags
@@ -490,7 +493,7 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(ctx context.Context, p
if err != nil {
return "", fmt.Errorf("error marshalling tailscaled config: %w", err)
}
mak.Set(&cfgSecret.StringData, tsoperator.TailscaledConfigFileName(cap), string(cfgJSON))
mak.Set(&cfgSecret.Data, tsoperator.TailscaledConfigFileName(cap), cfgJSON)
}
// The config sha256 sum is a value for a hash annotation used to trigger
@@ -520,12 +523,14 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(ctx context.Context, p
}
if existingCfgSecret != nil {
logger.Debugf("patching the existing ProxyGroup config Secret %s", cfgSecret.Name)
if err := r.Patch(ctx, cfgSecret, client.MergeFrom(existingCfgSecret)); err != nil {
return "", err
if !apiequality.Semantic.DeepEqual(existingCfgSecret, cfgSecret) {
logger.Debugf("Updating the existing ProxyGroup config Secret %s", cfgSecret.Name)
if err := r.Update(ctx, cfgSecret); err != nil {
return "", err
}
}
} else {
logger.Debugf("creating a new config Secret %s for the ProxyGroup", cfgSecret.Name)
logger.Debugf("Creating a new config Secret %s for the ProxyGroup", cfgSecret.Name)
if err := r.Create(ctx, cfgSecret); err != nil {
return "", err
}
@@ -645,7 +650,7 @@ func (r *ProxyGroupReconciler) getNodeMetadata(ctx context.Context, pg *tsapi.Pr
return nil, fmt.Errorf("unexpected secret %s was labelled as owned by the ProxyGroup %s: %w", secret.Name, pg.Name, err)
}
id, dnsName, ok, err := getNodeMetadata(ctx, &secret)
prefs, ok, err := getDevicePrefs(&secret)
if err != nil {
return nil, err
}
@@ -656,8 +661,8 @@ func (r *ProxyGroupReconciler) getNodeMetadata(ctx context.Context, pg *tsapi.Pr
nm := nodeMetadata{
ordinal: ordinal,
stateSecret: &secret,
tsID: id,
dnsName: dnsName,
tsID: prefs.Config.NodeID,
dnsName: prefs.Config.UserProfile.LoginName,
}
pod := &corev1.Pod{}
if err := r.Get(ctx, client.ObjectKey{Namespace: r.tsNamespace, Name: secret.Name}, pod); err != nil && !apierrors.IsNotFound(err) {

View File

@@ -178,7 +178,15 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
corev1.EnvVar{
Name: "TS_SERVE_CONFIG",
Value: fmt.Sprintf("/etc/proxies/%s", serveConfigKey),
})
},
corev1.EnvVar{
// Run proxies in cert share mode to
// ensure that only one TLS cert is
// issued for an HA Ingress.
Name: "TS_EXPERIMENTAL_CERT_SHARE",
Value: "true",
},
)
}
return append(c.Env, envs...)
}()
@@ -225,6 +233,13 @@ func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role {
OwnerReferences: pgOwnerReference(pg),
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Resources: []string{"secrets"},
Verbs: []string{
"list",
},
},
{
APIGroups: []string{""},
Resources: []string{"secrets"},
@@ -318,9 +333,9 @@ func pgIngressCM(pg *tsapi.ProxyGroup, namespace string) *corev1.ConfigMap {
}
}
func pgSecretLabels(pgName, typ string) map[string]string {
func pgSecretLabels(pgName, secretType string) map[string]string {
return pgLabels(pgName, map[string]string{
labelSecretType: typ, // "config" or "state".
kubetypes.LabelSecretType: secretType, // "config" or "state".
})
}
@@ -330,7 +345,7 @@ func pgLabels(pgName string, customLabels map[string]string) map[string]string {
l[k] = v
}
l[LabelManaged] = "true"
l[kubetypes.LabelManaged] = "true"
l[LabelParentType] = "proxygroup"
l[LabelParentName] = pgName

View File

@@ -247,7 +247,6 @@ func TestProxyGroup(t *testing.T) {
// The fake client does not clean up objects whose owner has been
// deleted, so we can't test for the owned resources getting deleted.
})
}
func TestProxyGroupTypes(t *testing.T) {
@@ -417,6 +416,7 @@ func TestProxyGroupTypes(t *testing.T) {
}
verifyEnvVar(t, sts, "TS_INTERNAL_APP", kubetypes.AppProxyGroupIngress)
verifyEnvVar(t, sts, "TS_SERVE_CONFIG", "/etc/proxies/serve-config.json")
verifyEnvVar(t, sts, "TS_EXPERIMENTAL_CERT_SHARE", "true")
// Verify ConfigMap volume mount
cmName := fmt.Sprintf("%s-ingress-config", pg.Name)
@@ -475,8 +475,6 @@ func TestIngressAdvertiseServicesConfigPreserved(t *testing.T) {
Name: pgConfigSecretName(pgName, 0),
Namespace: tsNamespace,
},
// Write directly to Data because the fake client doesn't copy the write-only
// StringData field across to Data for us.
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): existingConfigBytes,
},
@@ -514,10 +512,64 @@ func TestIngressAdvertiseServicesConfigPreserved(t *testing.T) {
Namespace: tsNamespace,
ResourceVersion: "2",
},
StringData: map[string]string{
tsoperator.TailscaledConfigFileName(106): string(expectedConfigBytes),
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): expectedConfigBytes,
},
}, omitSecretData)
})
}
func proxyClassesForLEStagingTest() (*tsapi.ProxyClass, *tsapi.ProxyClass, *tsapi.ProxyClass) {
pcLEStaging := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{
Name: "le-staging",
Generation: 1,
},
Spec: tsapi.ProxyClassSpec{
UseLetsEncryptStagingEnvironment: true,
},
}
pcLEStagingFalse := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{
Name: "le-staging-false",
Generation: 1,
},
Spec: tsapi.ProxyClassSpec{
UseLetsEncryptStagingEnvironment: false,
},
}
pcOther := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{
Name: "other",
Generation: 1,
},
Spec: tsapi.ProxyClassSpec{},
}
return pcLEStaging, pcLEStagingFalse, pcOther
}
func setProxyClassReady(t *testing.T, fc client.Client, cl *tstest.Clock, name string) *tsapi.ProxyClass {
t.Helper()
pc := &tsapi.ProxyClass{}
if err := fc.Get(context.Background(), client.ObjectKey{Name: name}, pc); err != nil {
t.Fatal(err)
}
pc.Status = tsapi.ProxyClassStatus{
Conditions: []metav1.Condition{{
Type: string(tsapi.ProxyClassReady),
Status: metav1.ConditionTrue,
Reason: reasonProxyClassValid,
Message: reasonProxyClassValid,
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
ObservedGeneration: pc.Generation,
}},
}
if err := fc.Status().Update(context.Background(), pc); err != nil {
t.Fatal(err)
}
return pc
}
func verifyProxyGroupCounts(t *testing.T, r *ProxyGroupReconciler, wantIngress, wantEgress int) {
@@ -543,6 +595,16 @@ func verifyEnvVar(t *testing.T, sts *appsv1.StatefulSet, name, expectedValue str
t.Errorf("%s environment variable not found", name)
}
func verifyEnvVarNotPresent(t *testing.T, sts *appsv1.StatefulSet, name string) {
t.Helper()
for _, env := range sts.Spec.Template.Spec.Containers[0].Env {
if env.Name == name {
t.Errorf("environment variable %s should not be present", name)
return
}
}
}
func expectProxyGroupResources(t *testing.T, fc client.WithWatch, pg *tsapi.ProxyGroup, shouldExist bool, cfgHash string, proxyClass *tsapi.ProxyClass) {
t.Helper()
@@ -621,10 +683,145 @@ func addNodeIDToStateSecrets(t *testing.T, fc client.WithWatch, pg *tsapi.ProxyG
}
}
// The operator mostly writes to StringData and reads from Data, but the fake
// client doesn't copy StringData across to Data on write. When comparing actual
// vs expected Secrets, use this function to only check what the operator writes
// to StringData.
func omitSecretData(secret *corev1.Secret) {
secret.Data = nil
func TestProxyGroupLetsEncryptStaging(t *testing.T) {
cl := tstest.NewClock(tstest.ClockOpts{})
zl := zap.Must(zap.NewDevelopment())
// Set up test cases- most are shared with non-HA Ingress.
type proxyGroupLETestCase struct {
leStagingTestCase
pgType tsapi.ProxyGroupType
}
pcLEStaging, pcLEStagingFalse, pcOther := proxyClassesForLEStagingTest()
sharedTestCases := testCasesForLEStagingTests(pcLEStaging, pcLEStagingFalse, pcOther)
var tests []proxyGroupLETestCase
for _, tt := range sharedTestCases {
tests = append(tests, proxyGroupLETestCase{
leStagingTestCase: tt,
pgType: tsapi.ProxyGroupTypeIngress,
})
}
tests = append(tests, proxyGroupLETestCase{
leStagingTestCase: leStagingTestCase{
name: "egress_pg_with_staging_proxyclass",
proxyClassPerResource: "le-staging",
useLEStagingEndpoint: false,
},
pgType: tsapi.ProxyGroupTypeEgress,
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme)
// Pre-populate the fake client with ProxyClasses.
builder = builder.WithObjects(pcLEStaging, pcLEStagingFalse, pcOther).
WithStatusSubresource(pcLEStaging, pcLEStagingFalse, pcOther)
fc := builder.Build()
// If the test case needs a ProxyClass to exist, ensure it is set to Ready.
if tt.proxyClassPerResource != "" || tt.defaultProxyClass != "" {
name := tt.proxyClassPerResource
if name == "" {
name = tt.defaultProxyClass
}
setProxyClassReady(t, fc, cl, name)
}
// Create ProxyGroup
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
Spec: tsapi.ProxyGroupSpec{
Type: tt.pgType,
Replicas: ptr.To[int32](1),
ProxyClass: tt.proxyClassPerResource,
},
}
mustCreate(t, fc, pg)
reconciler := &ProxyGroupReconciler{
tsNamespace: tsNamespace,
proxyImage: testProxyImage,
defaultTags: []string{"tag:test"},
defaultProxyClass: tt.defaultProxyClass,
Client: fc,
tsClient: &fakeTSClient{},
l: zl.Sugar(),
clock: cl,
}
expectReconciled(t, reconciler, "", pg.Name)
// Verify that the StatefulSet created for ProxyGrup has
// the expected setting for the staging endpoint.
sts := &appsv1.StatefulSet{}
if err := fc.Get(context.Background(), client.ObjectKey{Namespace: tsNamespace, Name: pg.Name}, sts); err != nil {
t.Fatalf("failed to get StatefulSet: %v", err)
}
if tt.useLEStagingEndpoint {
verifyEnvVar(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL", letsEncryptStagingEndpoint)
} else {
verifyEnvVarNotPresent(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL")
}
})
}
}
type leStagingTestCase struct {
name string
// ProxyClass set on ProxyGroup or Ingress resource.
proxyClassPerResource string
// Default ProxyClass.
defaultProxyClass string
useLEStagingEndpoint bool
}
// Shared test cases for LE staging endpoint configuration for ProxyGroup and
// non-HA Ingress.
func testCasesForLEStagingTests(pcLEStaging, pcLEStagingFalse, pcOther *tsapi.ProxyClass) []leStagingTestCase {
return []leStagingTestCase{
{
name: "with_staging_proxyclass",
proxyClassPerResource: "le-staging",
useLEStagingEndpoint: true,
},
{
name: "with_staging_proxyclass_false",
proxyClassPerResource: "le-staging-false",
useLEStagingEndpoint: false,
},
{
name: "with_other_proxyclass",
proxyClassPerResource: "other",
useLEStagingEndpoint: false,
},
{
name: "no_proxyclass",
proxyClassPerResource: "",
useLEStagingEndpoint: false,
},
{
name: "with_default_staging_proxyclass",
proxyClassPerResource: "",
defaultProxyClass: "le-staging",
useLEStagingEndpoint: true,
},
{
name: "with_default_other_proxyclass",
proxyClassPerResource: "",
defaultProxyClass: "other",
useLEStagingEndpoint: false,
},
{
name: "with_default_staging_proxyclass_false",
proxyClassPerResource: "",
defaultProxyClass: "le-staging-false",
useLEStagingEndpoint: false,
},
}
}

View File

@@ -44,11 +44,9 @@ const (
// Labels that the operator sets on StatefulSets and Pods. If you add a
// new label here, do also add it to tailscaleManagedLabels var to
// ensure that it does not get overwritten by ProxyClass configuration.
LabelManaged = "tailscale.com/managed"
LabelParentType = "tailscale.com/parent-resource-type"
LabelParentName = "tailscale.com/parent-resource"
LabelParentNamespace = "tailscale.com/parent-resource-ns"
labelSecretType = "tailscale.com/secret-type" // "config" or "state".
// LabelProxyClass can be set by users on tailscale Ingresses and Services that define cluster ingress or
// cluster egress, to specify that configuration in this ProxyClass should be applied to resources created for
@@ -104,11 +102,13 @@ const (
envVarTSLocalAddrPort = "TS_LOCAL_ADDR_PORT"
defaultLocalAddrPort = 9002 // metrics and health check port
letsEncryptStagingEndpoint = "https://acme-staging-v02.api.letsencrypt.org/directory"
)
var (
// tailscaleManagedLabels are label keys that tailscale operator sets on StatefulSets and Pods.
tailscaleManagedLabels = []string{LabelManaged, LabelParentType, LabelParentName, LabelParentNamespace, "app"}
tailscaleManagedLabels = []string{kubetypes.LabelManaged, LabelParentType, LabelParentName, LabelParentNamespace, "app"}
// tailscaleManagedAnnotations are annotation keys that tailscale operator sets on StatefulSets and Pods.
tailscaleManagedAnnotations = []string{podAnnotationLastSetClusterIP, podAnnotationLastSetTailnetTargetIP, podAnnotationLastSetTailnetTargetFQDN, podAnnotationLastSetConfigFileHash}
)
@@ -785,6 +785,17 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet,
enableEndpoints(ss, metricsEnabled, debugEnabled)
}
}
if pc.Spec.UseLetsEncryptStagingEnvironment && (stsCfg.proxyType == proxyTypeIngressResource || stsCfg.proxyType == string(tsapi.ProxyGroupTypeIngress)) {
for i, c := range ss.Spec.Template.Spec.Containers {
if c.Name == "tailscale" {
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env, corev1.EnvVar{
Name: "TS_DEBUG_ACME_DIRECTORY_URL",
Value: letsEncryptStagingEndpoint,
})
break
}
}
}
if pc.Spec.StatefulSet == nil {
return ss

View File

@@ -21,6 +21,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/ptr"
)
@@ -156,8 +157,8 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
// Set a couple additional fields so we can test that we don't
// mistakenly override those.
labels := map[string]string{
LabelManaged: "true",
LabelParentName: "foo",
kubetypes.LabelManaged: "true",
LabelParentName: "foo",
}
annots := map[string]string{
podAnnotationLastSetClusterIP: "1.2.3.4",
@@ -303,28 +304,28 @@ func Test_mergeStatefulSetLabelsOrAnnots(t *testing.T) {
}{
{
name: "no custom labels specified and none present in current labels, return current labels",
current: map[string]string{LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
want: map[string]string{LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
current: map[string]string{kubetypes.LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
want: map[string]string{kubetypes.LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
managed: tailscaleManagedLabels,
},
{
name: "no custom labels specified, but some present in current labels, return tailscale managed labels only from the current labels",
current: map[string]string{"foo": "bar", "something.io/foo": "bar", LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
want: map[string]string{LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
current: map[string]string{"foo": "bar", "something.io/foo": "bar", kubetypes.LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
want: map[string]string{kubetypes.LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
managed: tailscaleManagedLabels,
},
{
name: "custom labels specified, current labels only contain tailscale managed labels, return a union of both",
current: map[string]string{LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
current: map[string]string{kubetypes.LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
custom: map[string]string{"foo": "bar", "something.io/foo": "bar"},
want: map[string]string{"foo": "bar", "something.io/foo": "bar", LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
want: map[string]string{"foo": "bar", "something.io/foo": "bar", kubetypes.LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
managed: tailscaleManagedLabels,
},
{
name: "custom labels specified, current labels contain tailscale managed labels and custom labels, some of which re not present in the new custom labels, return a union of managed labels and the desired custom labels",
current: map[string]string{"foo": "bar", "bar": "baz", "app": "1234", LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
current: map[string]string{"foo": "bar", "bar": "baz", "app": "1234", kubetypes.LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
custom: map[string]string{"foo": "bar", "something.io/foo": "bar"},
want: map[string]string{"foo": "bar", "something.io/foo": "bar", "app": "1234", LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
want: map[string]string{"foo": "bar", "something.io/foo": "bar", "app": "1234", kubetypes.LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"},
managed: tailscaleManagedLabels,
},
{

View File

@@ -84,10 +84,10 @@ func childResourceLabels(name, ns, typ string) map[string]string {
// proxying. Instead, we have to do our own filtering and tracking with
// labels.
return map[string]string{
LabelManaged: "true",
LabelParentName: name,
LabelParentNamespace: ns,
LabelParentType: typ,
kubetypes.LabelManaged: "true",
LabelParentName: name,
LabelParentNamespace: ns,
LabelParentType: typ,
}
}

View File

@@ -32,6 +32,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
"tailscale.com/types/ptr"
"tailscale.com/util/mak"
@@ -563,10 +564,10 @@ func expectedSecret(t *testing.T, cl client.Client, opts configOpts) *corev1.Sec
func findGenName(t *testing.T, client client.Client, ns, name, typ string) (full, noSuffix string) {
t.Helper()
labels := map[string]string{
LabelManaged: "true",
LabelParentName: name,
LabelParentNamespace: ns,
LabelParentType: typ,
kubetypes.LabelManaged: "true",
LabelParentName: name,
LabelParentNamespace: ns,
LabelParentType: typ,
}
s, err := getSingleObject[corev1.Secret](context.Background(), client, "operator-ns", labels)
if err != nil {

View File

@@ -230,7 +230,7 @@ func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsr *tsapi.Reco
func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Recorder) (bool, error) {
logger := r.logger(tsr.Name)
id, _, ok, err := r.getNodeMetadata(ctx, tsr.Name)
prefs, ok, err := r.getDevicePrefs(ctx, tsr.Name)
if err != nil {
return false, err
}
@@ -243,6 +243,7 @@ func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Record
return true, nil
}
id := string(prefs.Config.NodeID)
logger.Debugf("deleting device %s from control", string(id))
if err := r.tsClient.DeleteDevice(ctx, string(id)); err != nil {
errResp := &tailscale.ErrResponse{}
@@ -327,34 +328,33 @@ func (r *RecorderReconciler) getStateSecret(ctx context.Context, tsrName string)
return secret, nil
}
func (r *RecorderReconciler) getNodeMetadata(ctx context.Context, tsrName string) (id tailcfg.StableNodeID, dnsName string, ok bool, err error) {
func (r *RecorderReconciler) getDevicePrefs(ctx context.Context, tsrName string) (prefs prefs, ok bool, err error) {
secret, err := r.getStateSecret(ctx, tsrName)
if err != nil || secret == nil {
return "", "", false, err
return prefs, false, err
}
return getNodeMetadata(ctx, secret)
return getDevicePrefs(secret)
}
// getNodeMetadata returns 'ok == true' iff the node ID is found. The dnsName
// getDevicePrefs returns 'ok == true' iff the node ID is found. The dnsName
// is expected to always be non-empty if the node ID is, but not required.
func getNodeMetadata(ctx context.Context, secret *corev1.Secret) (id tailcfg.StableNodeID, dnsName string, ok bool, err error) {
func getDevicePrefs(secret *corev1.Secret) (prefs prefs, ok bool, err error) {
// TODO(tomhjp): Should maybe use ipn to parse the following info instead.
currentProfile, ok := secret.Data[currentProfileKey]
if !ok {
return "", "", false, nil
return prefs, false, nil
}
profileBytes, ok := secret.Data[string(currentProfile)]
if !ok {
return "", "", false, nil
return prefs, false, nil
}
var profile profile
if err := json.Unmarshal(profileBytes, &profile); err != nil {
return "", "", false, fmt.Errorf("failed to extract node profile info from state Secret %s: %w", secret.Name, err)
if err := json.Unmarshal(profileBytes, &prefs); err != nil {
return prefs, false, fmt.Errorf("failed to extract node profile info from state Secret %s: %w", secret.Name, err)
}
ok = profile.Config.NodeID != ""
return tailcfg.StableNodeID(profile.Config.NodeID), profile.Config.UserProfile.LoginName, ok, nil
ok = prefs.Config.NodeID != ""
return prefs, ok, nil
}
func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsrName string) (d tsapi.RecorderTailnetDevice, ok bool, err error) {
@@ -367,14 +367,14 @@ func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsrName string)
}
func getDeviceInfo(ctx context.Context, tsClient tsClient, secret *corev1.Secret) (d tsapi.RecorderTailnetDevice, ok bool, err error) {
nodeID, dnsName, ok, err := getNodeMetadata(ctx, secret)
prefs, ok, err := getDevicePrefs(secret)
if !ok || err != nil {
return tsapi.RecorderTailnetDevice{}, false, err
}
// TODO(tomhjp): The profile info doesn't include addresses, which is why we
// need the API. Should we instead update the profile to include addresses?
device, err := tsClient.Device(ctx, string(nodeID), nil)
device, err := tsClient.Device(ctx, string(prefs.Config.NodeID), nil)
if err != nil {
return tsapi.RecorderTailnetDevice{}, false, fmt.Errorf("failed to get device info from API: %w", err)
}
@@ -383,20 +383,25 @@ func getDeviceInfo(ctx context.Context, tsClient tsClient, secret *corev1.Secret
Hostname: device.Hostname,
TailnetIPs: device.Addresses,
}
if dnsName != "" {
if dnsName := prefs.Config.UserProfile.LoginName; dnsName != "" {
d.URL = fmt.Sprintf("https://%s", dnsName)
}
return d, true, nil
}
type profile struct {
// [prefs] is a subset of the ipn.Prefs struct used for extracting information
// from the state Secret of Tailscale devices.
type prefs struct {
Config struct {
NodeID string `json:"NodeID"`
NodeID tailcfg.StableNodeID `json:"NodeID"`
UserProfile struct {
// LoginName is the MagicDNS name of the device, e.g. foo.tail-scale.ts.net.
LoginName string `json:"LoginName"`
} `json:"UserProfile"`
} `json:"Config"`
AdvertiseServices []string `json:"AdvertiseServices"`
}
func markedForDeletion(obj metav1.Object) bool {

View File

@@ -19,8 +19,25 @@
// header_property = username
// auto_sign_up = true
// whitelist = 127.0.0.1
// headers = Name:X-WEBAUTH-NAME
// headers = Email:X-Webauth-User, Name:X-Webauth-Name, Role:X-Webauth-Role
// enable_login_token = true
//
// You can use grants in Tailscale ACL to give users different roles in Grafana.
// For example, to give group:eng the Editor role, add the following to your ACLs:
//
// "grants": [
// {
// "src": ["group:eng"],
// "dst": ["tag:grafana"],
// "app": {
// "tailscale.com/cap/proxy-to-grafana": [{
// "role": "editor",
// }],
// },
// },
// ],
//
// If multiple roles are specified, the most permissive role is used.
package main
import (
@@ -49,6 +66,57 @@ var (
loginServer = flag.String("login-server", "", "URL to alternative control server. If empty, the default Tailscale control is used.")
)
// aclCap is the Tailscale ACL capability used to configure proxy-to-grafana.
const aclCap tailcfg.PeerCapability = "tailscale.com/cap/proxy-to-grafana"
// aclGrant is an access control rule that assigns Grafana permissions
// while provisioning a user.
type aclGrant struct {
// Role is one of: "viewer", "editor", "admin".
Role string `json:"role"`
}
// grafanaRole defines possible Grafana roles.
type grafanaRole int
const (
// Roles are ordered by their permissions, with the least permissive role first.
// If a user has multiple roles, the most permissive role is used.
ViewerRole grafanaRole = iota
EditorRole
AdminRole
)
// String returns the string representation of a grafanaRole.
// It is used as a header value in the HTTP request to Grafana.
func (r grafanaRole) String() string {
switch r {
case ViewerRole:
return "Viewer"
case EditorRole:
return "Editor"
case AdminRole:
return "Admin"
default:
// A safe default.
return "Viewer"
}
}
// roleFromString converts a string to a grafanaRole.
// It is used to parse the role from the ACL grant.
func roleFromString(s string) (grafanaRole, error) {
switch strings.ToLower(s) {
case "viewer":
return ViewerRole, nil
case "editor":
return EditorRole, nil
case "admin":
return AdminRole, nil
}
return ViewerRole, fmt.Errorf("unknown role: %q", s)
}
func main() {
flag.Parse()
if *hostname == "" || strings.Contains(*hostname, ".") {
@@ -134,7 +202,15 @@ func modifyRequest(req *http.Request, localClient *local.Client) {
return
}
user, err := getTailscaleUser(req.Context(), localClient, req.RemoteAddr)
// Delete any existing X-Webauth-* headers to prevent possible spoofing
// if getting Tailnet identity fails.
for h := range req.Header {
if strings.HasPrefix(h, "X-Webauth-") {
req.Header.Del(h)
}
}
user, role, err := getTailscaleIdentity(req.Context(), localClient, req.RemoteAddr)
if err != nil {
log.Printf("error getting Tailscale user: %v", err)
return
@@ -142,19 +218,33 @@ func modifyRequest(req *http.Request, localClient *local.Client) {
req.Header.Set("X-Webauth-User", user.LoginName)
req.Header.Set("X-Webauth-Name", user.DisplayName)
req.Header.Set("X-Webauth-Role", role.String())
}
func getTailscaleUser(ctx context.Context, localClient *local.Client, ipPort string) (*tailcfg.UserProfile, error) {
func getTailscaleIdentity(ctx context.Context, localClient *local.Client, ipPort string) (*tailcfg.UserProfile, grafanaRole, error) {
whois, err := localClient.WhoIs(ctx, ipPort)
if err != nil {
return nil, fmt.Errorf("failed to identify remote host: %w", err)
return nil, ViewerRole, fmt.Errorf("failed to identify remote host: %w", err)
}
if whois.Node.IsTagged() {
return nil, fmt.Errorf("tagged nodes are not users")
return nil, ViewerRole, fmt.Errorf("tagged nodes are not users")
}
if whois.UserProfile == nil || whois.UserProfile.LoginName == "" {
return nil, fmt.Errorf("failed to identify remote user")
return nil, ViewerRole, fmt.Errorf("failed to identify remote user")
}
return whois.UserProfile, nil
role := ViewerRole
grants, err := tailcfg.UnmarshalCapJSON[aclGrant](whois.CapMap, aclCap)
if err != nil {
return nil, ViewerRole, fmt.Errorf("failed to unmarshal ACL grants: %w", err)
}
for _, g := range grants {
r, err := roleFromString(g.Role)
if err != nil {
return nil, ViewerRole, fmt.Errorf("failed to parse role: %w", err)
}
role = max(role, r)
}
return whois.UserProfile, role, nil
}

View File

@@ -49,6 +49,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
tailscale.com from tailscale.com/version
tailscale.com/envknob from tailscale.com/tsweb+
tailscale.com/feature from tailscale.com/tsweb
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/metrics from tailscale.com/net/stunserver+
tailscale.com/net/netaddr from tailscale.com/net/tsaddr
@@ -57,8 +58,8 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
tailscale.com/net/tsaddr from tailscale.com/tsweb
tailscale.com/syncs from tailscale.com/metrics
tailscale.com/tailcfg from tailscale.com/version
tailscale.com/tsweb from tailscale.com/cmd/stund
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
tailscale.com/tsweb from tailscale.com/cmd/stund+
tailscale.com/tsweb/promvarz from tailscale.com/cmd/stund
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/ipproto from tailscale.com/tailcfg
@@ -194,7 +195,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
hash/maphash from go4.org/mem
html from net/http/pprof+
internal/abi from crypto/x509/internal/macos+
internal/asan from syscall+
internal/asan from internal/runtime/maps+
internal/bisect from internal/godebug
internal/bytealg from bytes+
internal/byteorder from crypto/cipher+
@@ -204,12 +205,12 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
internal/filepathlite from os+
internal/fmtsort from fmt
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from crypto/tls+
internal/godebug from crypto/internal/fips140deps/godebug+
internal/godebugs from internal/godebug+
internal/goexperiment from runtime+
internal/goexperiment from hash/maphash+
internal/goos from crypto/x509+
internal/itoa from internal/poll+
internal/msan from syscall+
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from io/fs+
internal/poll from net+

View File

@@ -15,6 +15,9 @@ import (
"tailscale.com/net/stunserver"
"tailscale.com/tsweb"
// Support for prometheus varz in tsweb
_ "tailscale.com/tsweb/promvarz"
)
var (

View File

@@ -136,6 +136,17 @@ func debugCmd() *ffcli.Command {
Exec: runLocalCreds,
ShortHelp: "Print how to access Tailscale LocalAPI",
},
{
Name: "localapi",
ShortUsage: "tailscale debug localapi [<method>] <path> [<body| \"-\">]",
Exec: runLocalAPI,
ShortHelp: "Call a LocalAPI method directly",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("localapi")
fs.BoolVar(&localAPIFlags.verbose, "v", false, "verbose; dump HTTP headers")
return fs
})(),
},
{
Name: "restun",
ShortUsage: "tailscale debug restun",
@@ -451,6 +462,81 @@ func runLocalCreds(ctx context.Context, args []string) error {
return nil
}
func looksLikeHTTPMethod(s string) bool {
if len(s) > len("OPTIONS") {
return false
}
for _, r := range s {
if r < 'A' || r > 'Z' {
return false
}
}
return true
}
var localAPIFlags struct {
verbose bool
}
func runLocalAPI(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("expected at least one argument")
}
method := "GET"
if looksLikeHTTPMethod(args[0]) {
method = args[0]
args = args[1:]
if len(args) == 0 {
return errors.New("expected at least one argument after method")
}
}
path := args[0]
if !strings.HasPrefix(path, "/localapi/") {
if !strings.Contains(path, "/") {
path = "/localapi/v0/" + path
} else {
path = "/localapi/" + path
}
}
var body io.Reader
if len(args) > 1 {
if args[1] == "-" {
fmt.Fprintf(Stderr, "# reading request body from stdin...\n")
all, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("reading Stdin: %q", err)
}
body = bytes.NewReader(all)
} else {
body = strings.NewReader(args[1])
}
}
req, err := http.NewRequest(method, "http://local-tailscaled.sock"+path, body)
if err != nil {
return err
}
fmt.Fprintf(Stderr, "# doing request %s %s\n", method, path)
res, err := localClient.DoLocalRequest(req)
if err != nil {
return err
}
is2xx := res.StatusCode >= 200 && res.StatusCode <= 299
if localAPIFlags.verbose {
res.Write(Stdout)
} else {
if !is2xx {
fmt.Fprintf(Stderr, "# Response status %s\n", res.Status)
}
io.Copy(Stdout, res.Body)
}
if is2xx {
return nil
}
return errors.New(res.Status)
}
type localClientRoundTripper struct{}
func (localClientRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {

View File

@@ -333,7 +333,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
image/color from github.com/skip2/go-qrcode+
image/png from github.com/skip2/go-qrcode
internal/abi from crypto/x509/internal/macos+
internal/asan from syscall+
internal/asan from internal/runtime/maps+
internal/bisect from internal/godebug
internal/bytealg from bytes+
internal/byteorder from crypto/cipher+
@@ -345,10 +345,10 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from archive/tar+
internal/godebugs from internal/godebug+
internal/goexperiment from runtime+
internal/goexperiment from hash/maphash+
internal/goos from crypto/x509+
internal/itoa from internal/poll+
internal/msan from syscall+
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from io/fs+
internal/poll from net+

View File

@@ -286,7 +286,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
L tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
L tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/kube/kubetypes from tailscale.com/envknob+
tailscale.com/licenses from tailscale.com/client/web
tailscale.com/log/filelogger from tailscale.com/logpolicy
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
@@ -589,7 +589,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
html from html/template+
html/template from github.com/gorilla/csrf
internal/abi from crypto/x509/internal/macos+
internal/asan from syscall+
internal/asan from internal/runtime/maps+
internal/bisect from internal/godebug
internal/bytealg from bytes+
internal/byteorder from crypto/cipher+
@@ -601,10 +601,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from archive/tar+
internal/godebugs from internal/godebug+
internal/goexperiment from runtime+
internal/goexperiment from hash/maphash+
internal/goos from crypto/x509+
internal/itoa from internal/poll+
internal/msan from syscall+
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from io/fs+
internal/poll from net+

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux || darwin || freebsd || openbsd) && !ts_omit_ssh
//go:build (linux || darwin || freebsd || openbsd || plan9) && !ts_omit_ssh
package main

View File

@@ -82,7 +82,9 @@ func defaultTunName() string {
// "utun" is recognized by wireguard-go/tun/tun_darwin.go
// as a magic value that uses/creates any free number.
return "utun"
case "plan9", "aix", "solaris", "illumos":
case "plan9":
return "auto"
case "aix", "solaris", "illumos":
return "userspace-networking"
case "linux":
switch distro.Get() {
@@ -180,6 +182,10 @@ func main() {
return
}
if runtime.GOOS == "plan9" && os.Getenv("_NETSHELL_CHILD_") != "" {
os.Args = []string{"tailscaled", "be-child", "plan9-netshell"}
}
if len(os.Args) > 1 {
sub := os.Args[1]
if fp, ok := subCommands[sub]; ok {
@@ -230,7 +236,18 @@ func main() {
// Only apply a default statepath when neither have been provided, so that a
// user may specify only --statedir if they wish.
if args.statepath == "" && args.statedir == "" {
args.statepath = paths.DefaultTailscaledStateFile()
if runtime.GOOS == "plan9" {
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("failed to get home directory: %v", err)
}
args.statedir = filepath.Join(home, "tailscale-state")
if err := os.MkdirAll(args.statedir, 0700); err != nil {
log.Fatalf("failed to create state directory: %v", err)
}
} else {
args.statepath = paths.DefaultTailscaledStateFile()
}
}
if args.disableLogs {
@@ -731,6 +748,12 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
return false, err
}
if runtime.GOOS == "plan9" {
// TODO(bradfitz): why don't we do this on all platforms?
// We should. Doing it just on plan9 for now conservatively.
sys.NetMon.Get().SetTailscaleInterfaceName(devName)
}
r, err := router.New(logf, dev, sys.NetMon.Get(), sys.HealthTracker())
if err != nil {
dev.Close()

View File

@@ -18,6 +18,9 @@ import (
"tailscale.com/derp/xdp"
"tailscale.com/net/netutil"
"tailscale.com/tsweb"
// Support for prometheus varz in tsweb
_ "tailscale.com/tsweb/promvarz"
)
var (

View File

@@ -246,6 +246,11 @@ func (a *Dialer) dial(ctx context.Context) (*ClientConn, error) {
results[i].conn = nil // so we don't close it in the defer
return conn, nil
}
if ctx.Err() != nil {
a.logf("controlhttp: context aborted dialing")
return nil, ctx.Err()
}
merr := multierr.New(errs...)
// If we get here, then we didn't get anywhere with our dial plan; fall back to just using DNS.

View File

@@ -429,10 +429,16 @@ func App() string {
// is a shared cert available.
func IsCertShareReadOnlyMode() bool {
m := String("TS_CERT_SHARE_MODE")
return m == modeRO
return m == "ro"
}
const modeRO = "ro"
// IsCertShareReadWriteMode returns true if this instance is the replica
// responsible for issuing and renewing TLS certs in an HA setup with certs
// shared between multiple replicas.
func IsCertShareReadWriteMode() bool {
m := String("TS_CERT_SHARE_MODE")
return m == "rw"
}
// CrashOnUnexpected reports whether the Tailscale client should panic
// on unexpected conditions. If TS_DEBUG_CRASH_ON_UNEXPECTED is set, that's

View File

@@ -40,7 +40,7 @@ func CanRunTailscaleSSH() error {
if version.IsSandboxedMacOS() {
return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.")
}
case "freebsd", "openbsd":
case "freebsd", "openbsd", "plan9":
default:
return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS)
}

6
go.mod
View File

@@ -3,6 +3,7 @@ module tailscale.com
go 1.24.0
require (
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f
filippo.io/mkcert v1.4.4
fyne.io/systray v1.11.0
github.com/akutz/memconn v0.1.0
@@ -36,6 +37,7 @@ require (
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874
github.com/go-logr/zapr v1.3.0
github.com/go-ole/go-ole v1.3.0
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
github.com/golang/snappy v0.0.4
@@ -84,12 +86,12 @@ require (
github.com/tailscale/setec v0.0.0-20250205144240-8898a29c3fbb
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e
github.com/tc-hib/winres v0.2.1
github.com/tcnksm/go-httpstat v0.2.0
github.com/toqueteos/webbrowser v1.2.0
github.com/u-root/u-root v0.12.0
github.com/u-root/u-root v0.14.0
github.com/vishvananda/netns v0.0.4
go.uber.org/zap v1.27.0
go4.org/mem v0.0.0-20240501181205-ae6ca9944745

20
go.sum
View File

@@ -2,6 +2,8 @@
4d63.com/gocheckcompilerdirectives v1.2.1/go.mod h1:yjDJSxmDTtIHHCqX0ufRYZDL6vQtMG7tJdKVeWwsqvs=
4d63.com/gochecknoglobals v0.2.1 h1:1eiorGsgHOFOuoOiJDy2psSrQbRdIHrlge0IJIkUgDc=
4d63.com/gochecknoglobals v0.2.1/go.mod h1:KRE8wtJB3CXCsb1xy421JfTHIIbmT3U5ruxw2Qu8fSU=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -389,6 +391,8 @@ github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsM
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.2 h1:Nea7b4icn8s57fTx1M5AI4qQT5HEM3rVUO8MuE6g80U=
github.com/go-xmlfmt/xmlfmt v1.1.2/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -545,8 +549,8 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/hugelgupf/vmtest v0.0.0-20240102225328-693afabdd27f h1:ov45/OzrJG8EKbGjn7jJZQJTN7Z1t73sFYNIRd64YlI=
github.com/hugelgupf/vmtest v0.0.0-20240102225328-693afabdd27f/go.mod h1:JoDrYMZpDPYo6uH9/f6Peqms3zNNWT2XiGgioMOIGuI=
github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1 h1:jWoR2Yqg8tzM0v6LAiP7i1bikZJu3gxpgvu3g1Lw+a0=
github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
@@ -922,8 +926,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U=
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
@@ -950,10 +954,10 @@ github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=
github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
github.com/u-root/gobusybox/src v0.0.0-20231228173702-b69f654846aa h1:unMPGGK/CRzfg923allsikmvk2l7beBeFPUNC4RVX/8=
github.com/u-root/gobusybox/src v0.0.0-20231228173702-b69f654846aa/go.mod h1:Zj4Tt22fJVn/nz/y6Ergm1SahR9dio1Zm/D2/S0TmXM=
github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs=
github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI=
github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a h1:eg5FkNoQp76ZsswyGZ+TjYqA/rhKefxK8BW7XOlQsxo=
github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a/go.mod h1:e/8TmrdreH0sZOw2DFKBaUV7bvDWRq6SeM9PzkuVM68=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=

View File

@@ -27,6 +27,8 @@ type VIPService struct {
Addrs []string `json:"addrs,omitempty"`
// Comment is an optional text string for display in the admin panel.
Comment string `json:"comment,omitempty"`
// Annotations are optional key-value pairs that can be used to store arbitrary metadata.
Annotations map[string]string `json:"annotations,omitempty"`
// Ports are the ports of a VIPService that will be configured via Tailscale serve config.
// If set, any node wishing to advertise this VIPService must have this port configured via Tailscale serve.
Ports []string `json:"ports,omitempty"`

View File

@@ -145,9 +145,15 @@ func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
mp.AppConnector = *c.AppConnector
mp.AppConnectorSet = true
}
// Configfile should be the source of truth for whether this node
// advertises any services. We need to ensure that each reload updates
// currently advertised services as else the transition from 'some
// services are advertised' to 'advertised services are empty/unset in
// conffile' would have no effect (especially given that an empty
// service slice would be omitted from the JSON config).
mp.AdvertiseServicesSet = true
if c.AdvertiseServices != nil {
mp.AdvertiseServices = c.AdvertiseServices
mp.AdvertiseServicesSet = true
}
return mp, nil
}

View File

@@ -2380,12 +2380,10 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
}
b.applyPrefsToHostinfoLocked(hostinfo, prefs)
b.setNetMapLocked(nil)
persistv := prefs.Persist().AsStruct()
if persistv == nil {
persistv = new(persist.Persist)
}
b.updateFilterLocked(nil, ipn.PrefsView{})
if b.portpoll != nil {
b.portpollOnce.Do(func() {
@@ -3481,18 +3479,20 @@ func (b *LocalBackend) onTailnetDefaultAutoUpdate(au bool) {
// can still manually enable auto-updates on this node.
return
}
b.logf("using tailnet default auto-update setting: %v", au)
prefsClone := prefs.AsStruct()
prefsClone.AutoUpdate.Apply = opt.NewBool(au)
_, err := b.editPrefsLockedOnEntry(&ipn.MaskedPrefs{
Prefs: *prefsClone,
AutoUpdateSet: ipn.AutoUpdatePrefsMask{
ApplySet: true,
},
}, unlock)
if err != nil {
b.logf("failed to apply tailnet-wide default for auto-updates (%v): %v", au, err)
return
if clientupdate.CanAutoUpdate() {
b.logf("using tailnet default auto-update setting: %v", au)
prefsClone := prefs.AsStruct()
prefsClone.AutoUpdate.Apply = opt.NewBool(au)
_, err := b.editPrefsLockedOnEntry(&ipn.MaskedPrefs{
Prefs: *prefsClone,
AutoUpdateSet: ipn.AutoUpdatePrefsMask{
ApplySet: true,
},
}, unlock)
if err != nil {
b.logf("failed to apply tailnet-wide default for auto-updates (%v): %v", au, err)
return
}
}
}
@@ -4968,7 +4968,7 @@ func (b *LocalBackend) authReconfig() {
return
}
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.ControlKnobs(), version.OS())
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.NetMon.Get(), b.sys.ControlKnobs(), version.OS())
rcfg := b.routerConfig(cfg, prefs, oneCGNATRoute)
err = b.e.Reconfig(cfg, rcfg, dcfg)
@@ -4992,7 +4992,7 @@ func (b *LocalBackend) authReconfig() {
//
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not
// a runtime.GOOS.
func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs, versionOS string) bool {
func shouldUseOneCGNATRoute(logf logger.Logf, mon *netmon.Monitor, controlKnobs *controlknobs.Knobs, versionOS string) bool {
if controlKnobs != nil {
// Explicit enabling or disabling always take precedence.
if v, ok := controlKnobs.OneCGNAT.Load().Get(); ok {
@@ -5001,13 +5001,18 @@ func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs,
}
}
if versionOS == "plan9" {
// Just temporarily during plan9 bringup to have fewer routes to debug.
return true
}
// Also prefer to do this on the Mac, so that we don't need to constantly
// update the network extension configuration (which is disruptive to
// Chrome, see https://github.com/tailscale/tailscale/issues/3102). Only
// use fine-grained routes if another interfaces is also using the CGNAT
// IP range.
if versionOS == "macOS" {
hasCGNATInterface, err := netmon.HasCGNATInterface()
hasCGNATInterface, err := mon.HasCGNATInterface()
if err != nil {
logf("shouldUseOneCGNATRoute: Could not determine if any interfaces use CGNAT: %v", err)
return false
@@ -5920,6 +5925,9 @@ func (b *LocalBackend) requestEngineStatusAndWait() {
b.logf("requestEngineStatusAndWait: got status update.")
}
// [controlclient.Auto] implements [auditlog.Transport].
var _ auditlog.Transport = (*controlclient.Auto)(nil)
// setControlClientLocked sets the control client to cc,
// which may be nil.
//
@@ -5927,12 +5935,12 @@ func (b *LocalBackend) requestEngineStatusAndWait() {
func (b *LocalBackend) setControlClientLocked(cc controlclient.Client) {
b.cc = cc
b.ccAuto, _ = cc.(*controlclient.Auto)
if b.auditLogger != nil {
if t, ok := b.cc.(auditlog.Transport); ok && b.auditLogger != nil {
if err := b.auditLogger.SetProfileID(b.pm.CurrentProfile().ID()); err != nil {
b.logf("audit logger set profile ID failure: %v", err)
}
if err := b.auditLogger.Start(b.ccAuto); err != nil {
if err := b.auditLogger.Start(t); err != nil {
b.logf("audit logger start failure: %v", err)
}
}
@@ -7531,6 +7539,7 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err
return nil
}
b.setNetMapLocked(nil) // Reset netmap.
b.updateFilterLocked(nil, ipn.PrefsView{})
// Reset the NetworkMap in the engine
b.e.SetNetworkMap(new(netmap.NetworkMap))
if prevCC := b.resetControlClientLocked(); prevCC != nil {

View File

@@ -1510,6 +1510,15 @@ func TestReconfigureAppConnector(t *testing.T) {
func TestBackfillAppConnectorRoutes(t *testing.T) {
// Create backend with an empty app connector.
b := newTestBackend(t)
// newTestBackend creates a backend with a non-nil netmap,
// but this test requires a nil netmap.
// Otherwise, instead of backfilling, [LocalBackend.reconfigAppConnectorLocked]
// uses the domains and routes from netmap's [appctype.AppConnectorAttr].
// Additionally, a non-nil netmap makes reconfigAppConnectorLocked
// asynchronous, resulting in a flaky test.
// Therefore, we set the netmap to nil to simulate a fresh backend start
// or a profile switch where the netmap is not yet available.
b.setNetMapLocked(nil)
if err := b.Start(ipn.Options{}); err != nil {
t.Fatal(err)
}
@@ -4745,32 +4754,133 @@ func TestLoginNotifications(t *testing.T) {
// TestConfigFileReload tests that the LocalBackend reloads its configuration
// when the configuration file changes.
func TestConfigFileReload(t *testing.T) {
cfg1 := `{"Hostname": "foo", "Version": "alpha0"}`
f := filepath.Join(t.TempDir(), "cfg")
must.Do(os.WriteFile(f, []byte(cfg1), 0600))
sys := new(tsd.System)
sys.InitialConfig = must.Get(conffile.Load(f))
lb := newTestLocalBackendWithSys(t, sys)
must.Do(lb.Start(ipn.Options{}))
lb.mu.Lock()
hn := lb.hostinfo.Hostname
lb.mu.Unlock()
if hn != "foo" {
t.Fatalf("got %q; want %q", hn, "foo")
type testCase struct {
name string
initial *conffile.Config
updated *conffile.Config
checkFn func(*testing.T, *LocalBackend)
}
cfg2 := `{"Hostname": "bar", "Version": "alpha0"}`
must.Do(os.WriteFile(f, []byte(cfg2), 0600))
if !must.Get(lb.ReloadConfig()) {
t.Fatal("reload failed")
tests := []testCase{
{
name: "hostname_change",
initial: &conffile.Config{
Parsed: ipn.ConfigVAlpha{
Version: "alpha0",
Hostname: ptr.To("initial-host"),
},
},
updated: &conffile.Config{
Parsed: ipn.ConfigVAlpha{
Version: "alpha0",
Hostname: ptr.To("updated-host"),
},
},
checkFn: func(t *testing.T, b *LocalBackend) {
if got := b.Prefs().Hostname(); got != "updated-host" {
t.Errorf("hostname = %q; want updated-host", got)
}
},
},
{
name: "start_advertising_services",
initial: &conffile.Config{
Parsed: ipn.ConfigVAlpha{
Version: "alpha0",
},
},
updated: &conffile.Config{
Parsed: ipn.ConfigVAlpha{
Version: "alpha0",
AdvertiseServices: []string{"svc:abc", "svc:def"},
},
},
checkFn: func(t *testing.T, b *LocalBackend) {
if got := b.Prefs().AdvertiseServices().AsSlice(); !reflect.DeepEqual(got, []string{"svc:abc", "svc:def"}) {
t.Errorf("AdvertiseServices = %v; want [svc:abc, svc:def]", got)
}
},
},
{
name: "change_advertised_services",
initial: &conffile.Config{
Parsed: ipn.ConfigVAlpha{
Version: "alpha0",
AdvertiseServices: []string{"svc:abc", "svc:def"},
},
},
updated: &conffile.Config{
Parsed: ipn.ConfigVAlpha{
Version: "alpha0",
AdvertiseServices: []string{"svc:abc", "svc:ghi"},
},
},
checkFn: func(t *testing.T, b *LocalBackend) {
if got := b.Prefs().AdvertiseServices().AsSlice(); !reflect.DeepEqual(got, []string{"svc:abc", "svc:ghi"}) {
t.Errorf("AdvertiseServices = %v; want [svc:abc, svc:ghi]", got)
}
},
},
{
name: "unset_advertised_services",
initial: &conffile.Config{
Parsed: ipn.ConfigVAlpha{
Version: "alpha0",
AdvertiseServices: []string{"svc:abc"},
},
},
updated: &conffile.Config{
Parsed: ipn.ConfigVAlpha{
Version: "alpha0",
},
},
checkFn: func(t *testing.T, b *LocalBackend) {
if b.Prefs().AdvertiseServices().Len() != 0 {
t.Errorf("got %d AdvertiseServices wants none", b.Prefs().AdvertiseServices().Len())
}
},
},
}
lb.mu.Lock()
hn = lb.hostinfo.Hostname
lb.mu.Unlock()
if hn != "bar" {
t.Fatalf("got %q; want %q", hn, "bar")
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tailscale.conf")
// Write initial config
initialJSON, err := json.Marshal(tc.initial.Parsed)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, initialJSON, 0644); err != nil {
t.Fatal(err)
}
// Create backend with initial config
tc.initial.Path = path
tc.initial.Raw = initialJSON
sys := &tsd.System{
InitialConfig: tc.initial,
}
b := newTestLocalBackendWithSys(t, sys)
// Update config file
updatedJSON, err := json.Marshal(tc.updated.Parsed)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, updatedJSON, 0644); err != nil {
t.Fatal(err)
}
// Trigger reload
if ok, err := b.ReloadConfig(); !ok || err != nil {
t.Fatalf("ReloadConfig() = %v, %v; want true, nil", ok, err)
}
// Check outcome
tc.checkFn(t, b)
})
}
}

View File

@@ -481,7 +481,7 @@ func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Re
fmt.Fprintf(w, "<h3>Could not get the default route: %s</h3>\n", html.EscapeString(err.Error()))
}
if hasCGNATInterface, err := netmon.HasCGNATInterface(); hasCGNATInterface {
if hasCGNATInterface, err := h.ps.b.sys.NetMon.Get().HasCGNATInterface(); hasCGNATInterface {
fmt.Fprintln(w, "<p>There is another interface using the CGNAT range.</p>")
} else if err != nil {
fmt.Fprintf(w, "<p>Could not check for CGNAT interfaces: %s</p>\n", html.EscapeString(err.Error()))

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || (darwin && !ios) || freebsd || openbsd
//go:build linux || (darwin && !ios) || freebsd || openbsd || plan9
package ipnlocal

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || (!linux && !darwin && !freebsd && !openbsd)
//go:build ios || (!linux && !darwin && !freebsd && !openbsd && !plan9)
package ipnlocal

View File

@@ -735,12 +735,10 @@ func TestStateMachine(t *testing.T) {
// b.Shutdown() explicitly ourselves.
previousCC.assertShutdown(false)
// Note: unpause happens because ipn needs to get at least one netmap
// on startup, otherwise UIs can't show the node list, login
// name, etc when in state ipn.Stopped.
// Arguably they shouldn't try. But they currently do.
nn := notifies.drain(2)
cc.assertCalls("New", "Login")
// We already have a netmap for this node,
// and WantRunning is false, so cc should be paused.
cc.assertCalls("New", "Login", "pause")
c.Assert(nn[0].Prefs, qt.IsNotNil)
c.Assert(nn[1].State, qt.IsNotNil)
c.Assert(nn[0].Prefs.WantRunning(), qt.IsFalse)
@@ -751,7 +749,11 @@ func TestStateMachine(t *testing.T) {
// When logged in but !WantRunning, ipn leaves us unpaused to retrieve
// the first netmap. Simulate that netmap being received, after which
// it should pause us, to avoid wasting CPU retrieving unnecessarily
// additional netmap updates.
// additional netmap updates. Since our LocalBackend instance already
// has a netmap, we will reset it to nil to simulate the first netmap
// retrieval.
b.setNetMapLocked(nil)
cc.assertCalls("unpause")
//
// TODO: really the various GUIs and prefs should be refactored to
// not require the netmap structure at all when starting while
@@ -853,7 +855,7 @@ func TestStateMachine(t *testing.T) {
// The last test case is the most common one: restarting when both
// logged in and WantRunning.
t.Logf("\n\nStart5")
notifies.expect(1)
notifies.expect(2)
c.Assert(b.Start(ipn.Options{}), qt.IsNil)
{
// NOTE: cc.Shutdown() is correct here, since we didn't call
@@ -861,30 +863,32 @@ func TestStateMachine(t *testing.T) {
previousCC.assertShutdown(false)
cc.assertCalls("New", "Login")
nn := notifies.drain(1)
nn := notifies.drain(2)
cc.assertCalls()
c.Assert(nn[0].Prefs, qt.IsNotNil)
c.Assert(nn[0].Prefs.LoggedOut(), qt.IsFalse)
c.Assert(nn[0].Prefs.WantRunning(), qt.IsTrue)
c.Assert(b.State(), qt.Equals, ipn.NoState)
// We're logged in and have a valid netmap, so we should
// be in the Starting state.
c.Assert(nn[1].State, qt.IsNotNil)
c.Assert(*nn[1].State, qt.Equals, ipn.Starting)
c.Assert(b.State(), qt.Equals, ipn.Starting)
}
// Control server accepts our valid key from before.
t.Logf("\n\nLoginFinished5")
notifies.expect(1)
notifies.expect(0)
cc.send(nil, "", true, &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(),
})
{
nn := notifies.drain(1)
notifies.drain(0)
cc.assertCalls()
// NOTE: No LoginFinished message since no interactive
// login was needed.
c.Assert(nn[0].State, qt.IsNotNil)
c.Assert(ipn.Starting, qt.Equals, *nn[0].State)
// NOTE: No prefs change this time. WantRunning stays true.
// We were in Starting in the first place, so that doesn't
// change either.
// change either, so we don't expect any notifications.
c.Assert(ipn.Starting, qt.Equals, b.State())
}
t.Logf("\n\nExpireKey")

View File

@@ -331,7 +331,7 @@ func (a *actor) Permissions(operatorUID string) (read, write bool) {
// checks here. Note that this permission model is being changed in
// tailscale/corp#18342.
return true, true
case "js":
case "js", "plan9":
return true, true
}
if a.ci.IsUnixSock() {

View File

@@ -13,11 +13,14 @@ import (
"strings"
"time"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
)
@@ -32,21 +35,37 @@ const (
reasonTailscaleStateLoadFailed = "TailscaleStateLoadFailed"
eventTypeWarning = "Warning"
eventTypeNormal = "Normal"
keyTLSCert = "tls.crt"
keyTLSKey = "tls.key"
)
// Store is an ipn.StateStore that uses a Kubernetes Secret for persistence.
type Store struct {
client kubeclient.Client
canPatch bool
secretName string
client kubeclient.Client
canPatch bool
secretName string // state Secret
certShareMode string // 'ro', 'rw', or empty
podName string
// memory holds the latest tailscale state. Writes write state to a kube Secret and memory, Reads read from
// memory.
// memory holds the latest tailscale state. Writes write state to a kube
// Secret and memory, Reads read from memory.
memory mem.Store
}
// New returns a new Store that persists to the named Secret.
func New(_ logger.Logf, secretName string) (*Store, error) {
// New returns a new Store that persists state to Kubernets Secret(s).
// Tailscale state is stored in a Secret named by the secretName parameter.
// TLS certs are stored and retrieved from state Secret or separate Secrets
// named after TLS endpoints if running in cert share mode.
func New(logf logger.Logf, secretName string) (*Store, error) {
c, err := newClient()
if err != nil {
return nil, err
}
return newWithClient(logf, c, secretName)
}
func newClient() (kubeclient.Client, error) {
c, err := kubeclient.New("tailscale-state-store")
if err != nil {
return nil, err
@@ -55,6 +74,10 @@ func New(_ logger.Logf, secretName string) (*Store, error) {
// Derive the API server address from the environment variables
c.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
return c, nil
}
func newWithClient(logf logger.Logf, c kubeclient.Client, secretName string) (*Store, error) {
canPatch, _, err := c.CheckSecretPermissions(context.Background(), secretName)
if err != nil {
return nil, err
@@ -63,11 +86,30 @@ func New(_ logger.Logf, secretName string) (*Store, error) {
client: c,
canPatch: canPatch,
secretName: secretName,
podName: os.Getenv("POD_NAME"),
}
if envknob.IsCertShareReadWriteMode() {
s.certShareMode = "rw"
} else if envknob.IsCertShareReadOnlyMode() {
s.certShareMode = "ro"
}
// Load latest state from kube Secret if it already exists.
if err := s.loadState(); err != nil && err != ipn.ErrStateNotExist {
return nil, fmt.Errorf("error loading state from kube Secret: %w", err)
}
// If we are in cert share mode, pre-load existing shared certs.
if s.certShareMode == "rw" || s.certShareMode == "ro" {
sel := s.certSecretSelector()
if err := s.loadCerts(context.Background(), sel); err != nil {
// We will attempt to again retrieve the certs from Secrets when a request for an HTTPS endpoint
// is received.
log.Printf("[unexpected] error loading TLS certs: %v", err)
}
}
if s.certShareMode == "ro" {
go s.runCertReload(context.Background(), logf)
}
return s, nil
}
@@ -84,27 +126,101 @@ func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
// WriteState implements the StateStore interface.
func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
return s.updateStateSecret(map[string][]byte{string(id): bs})
}
// WriteTLSCertAndKey writes a TLS cert and key to domain.crt, domain.key fields of a Tailscale Kubernetes node's state
// Secret.
func (s *Store) WriteTLSCertAndKey(domain string, cert, key []byte) error {
return s.updateStateSecret(map[string][]byte{domain + ".crt": cert, domain + ".key": key})
}
func (s *Store) updateStateSecret(data map[string][]byte) (err error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer func() {
if err == nil {
for id, bs := range data {
// The in-memory store does not distinguish between values read from state Secret on
// init and values written to afterwards. Values read from the state
// Secret will always be sanitized, so we also need to sanitize values written to store
// later, so that the Read logic can just lookup keys in sanitized form.
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
}
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
}
}()
return s.updateSecret(map[string][]byte{string(id): bs}, s.secretName)
}
// WriteTLSCertAndKey writes a TLS cert and key to domain.crt, domain.key fields
// of a Tailscale Kubernetes node's state Secret.
func (s *Store) WriteTLSCertAndKey(domain string, cert, key []byte) (err error) {
if s.certShareMode == "ro" {
log.Printf("[unexpected] TLS cert and key write in read-only mode")
}
if err := dnsname.ValidHostname(domain); err != nil {
return fmt.Errorf("invalid domain name %q: %w", domain, err)
}
defer func() {
// TODO(irbekrm): a read between these two separate writes would
// get a mismatched cert and key. Allow writing both cert and
// key to the memory store in a single, lock-protected operation.
if err == nil {
s.memory.WriteState(ipn.StateKey(domain+".crt"), cert)
s.memory.WriteState(ipn.StateKey(domain+".key"), key)
}
}()
secretName := s.secretName
data := map[string][]byte{
domain + ".crt": cert,
domain + ".key": key,
}
// If we run in cert share mode, cert and key for a DNS name are written
// to a separate Secret.
if s.certShareMode == "rw" {
secretName = domain
data = map[string][]byte{
keyTLSCert: cert,
keyTLSKey: key,
}
}
return s.updateSecret(data, secretName)
}
// ReadTLSCertAndKey reads a TLS cert and key from memory or from a
// domain-specific Secret. It first checks the in-memory store, if not found in
// memory and running cert store in read-only mode, looks up a Secret.
func (s *Store) ReadTLSCertAndKey(domain string) (cert, key []byte, err error) {
if err := dnsname.ValidHostname(domain); err != nil {
return nil, nil, fmt.Errorf("invalid domain name %q: %w", domain, err)
}
certKey := domain + ".crt"
keyKey := domain + ".key"
cert, err = s.memory.ReadState(ipn.StateKey(certKey))
if err == nil {
key, err = s.memory.ReadState(ipn.StateKey(keyKey))
if err == nil {
return cert, key, nil
}
}
if s.certShareMode != "ro" {
return nil, nil, ipn.ErrStateNotExist
}
// If we are in cert share read only mode, it is possible that a write
// replica just issued the TLS cert for this DNS name and it has not
// been loaded to store yet, so check the Secret.
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
secret, err := s.client.GetSecret(ctx, domain)
if err != nil {
if kubeclient.IsNotFoundErr(err) {
// TODO(irbekrm): we should return a more specific error
// that wraps ipn.ErrStateNotExist here.
return nil, nil, ipn.ErrStateNotExist
}
return nil, nil, fmt.Errorf("getting TLS Secret %q: %w", domain, err)
}
cert = secret.Data[keyTLSCert]
key = secret.Data[keyTLSKey]
if len(cert) == 0 || len(key) == 0 {
return nil, nil, ipn.ErrStateNotExist
}
// TODO(irbekrm): a read between these two separate writes would
// get a mismatched cert and key. Allow writing both cert and
// key to the memory store in a single lock-protected operation.
s.memory.WriteState(ipn.StateKey(certKey), cert)
s.memory.WriteState(ipn.StateKey(keyKey), key)
return cert, key, nil
}
func (s *Store) updateSecret(data map[string][]byte, secretName string) (err error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer func() {
if err != nil {
if err := s.client.Event(ctx, eventTypeWarning, reasonTailscaleStateUpdateFailed, err.Error()); err != nil {
log.Printf("kubestore: error creating tailscaled state update Event: %v", err)
@@ -116,17 +232,17 @@ func (s *Store) updateStateSecret(data map[string][]byte) (err error) {
}
cancel()
}()
secret, err := s.client.GetSecret(ctx, s.secretName)
secret, err := s.client.GetSecret(ctx, secretName)
if err != nil {
// If the Secret does not exist, create it with the required data.
if kubeclient.IsNotFoundErr(err) {
if kubeclient.IsNotFoundErr(err) && s.canCreateSecret(secretName) {
return s.client.CreateSecret(ctx, &kubeapi.Secret{
TypeMeta: kubeapi.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: kubeapi.ObjectMeta{
Name: s.secretName,
Name: secretName,
},
Data: func(m map[string][]byte) map[string][]byte {
d := make(map[string][]byte, len(m))
@@ -137,9 +253,9 @@ func (s *Store) updateStateSecret(data map[string][]byte) (err error) {
}(data),
})
}
return err
return fmt.Errorf("error getting Secret %s: %w", secretName, err)
}
if s.canPatch {
if s.canPatchSecret(secretName) {
var m []kubeclient.JSONPatch
// If the user has pre-created a Secret with no data, we need to ensure the top level /data field.
if len(secret.Data) == 0 {
@@ -166,8 +282,8 @@ func (s *Store) updateStateSecret(data map[string][]byte) (err error) {
})
}
}
if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
return fmt.Errorf("error patching Secret %s: %w", s.secretName, err)
if err := s.client.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, m); err != nil {
return fmt.Errorf("error patching Secret %s: %w", secretName, err)
}
return nil
}
@@ -176,9 +292,9 @@ func (s *Store) updateStateSecret(data map[string][]byte) (err error) {
mak.Set(&secret.Data, sanitizeKey(key), val)
}
if err := s.client.UpdateSecret(ctx, secret); err != nil {
return err
return fmt.Errorf("error updating Secret %s: %w", s.secretName, err)
}
return err
return nil
}
func (s *Store) loadState() (err error) {
@@ -202,6 +318,96 @@ func (s *Store) loadState() (err error) {
return nil
}
// runCertReload relists and reloads all TLS certs for endpoints shared by this
// node from Secrets other than the state Secret to ensure that renewed certs get eventually loaded.
// It is not critical to reload a cert immediately after
// renewal, so a daily check is acceptable.
// Currently (3/2025) this is only used for the shared HA Ingress certs on 'read' replicas.
// Note that if shared certs are not found in memory on an HTTPS request, we
// do a Secret lookup, so this mechanism does not need to ensure that newly
// added Ingresses' certs get loaded.
func (s *Store) runCertReload(ctx context.Context, logf logger.Logf) {
ticker := time.NewTicker(time.Hour * 24)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sel := s.certSecretSelector()
if err := s.loadCerts(ctx, sel); err != nil {
logf("[unexpected] error reloading TLS certs: %v", err)
}
}
}
}
// loadCerts lists all Secrets matching the provided selector and loads TLS
// certs and keys from those.
func (s *Store) loadCerts(ctx context.Context, sel map[string]string) error {
ss, err := s.client.ListSecrets(ctx, sel)
if err != nil {
return fmt.Errorf("error listing TLS Secrets: %w", err)
}
for _, secret := range ss.Items {
if !hasTLSData(&secret) {
continue
}
// Only load secrets that have valid domain names (ending in .ts.net)
if !strings.HasSuffix(secret.Name, ".ts.net") {
continue
}
s.memory.WriteState(ipn.StateKey(secret.Name)+".crt", secret.Data[keyTLSCert])
s.memory.WriteState(ipn.StateKey(secret.Name)+".key", secret.Data[keyTLSKey])
}
return nil
}
// canCreateSecret returns true if this node should be allowed to create the given
// Secret in its namespace.
func (s *Store) canCreateSecret(secret string) bool {
// Only allow creating the state Secret (and not TLS Secrets).
return secret == s.secretName
}
// canPatchSecret returns true if this node should be allowed to patch the given
// Secret.
func (s *Store) canPatchSecret(secret string) bool {
// For backwards compatibility reasons, setups where the proxies are not
// given PATCH permissions for state Secrets are allowed. For TLS
// Secrets, we should always have PATCH permissions.
if secret == s.secretName {
return s.canPatch
}
return true
}
// certSecretSelector returns a label selector that can be used to list all
// Secrets that aren't Tailscale state Secrets and contain TLS certificates for
// HTTPS endpoints that this node serves.
// Currently (3/2025) this only applies to the Kubernetes Operator's ingress
// ProxyGroup.
func (s *Store) certSecretSelector() map[string]string {
if s.podName == "" {
return map[string]string{}
}
p := strings.LastIndex(s.podName, "-")
if p == -1 {
return map[string]string{}
}
pgName := s.podName[:p]
return map[string]string{
kubetypes.LabelSecretType: "certs",
kubetypes.LabelManaged: "true",
"tailscale.com/proxy-group": pgName,
}
}
// hasTLSData returns true if the provided Secret contains non-empty TLS cert and key.
func hasTLSData(s *kubeapi.Secret) bool {
return len(s.Data[keyTLSCert]) != 0 && len(s.Data[keyTLSKey]) != 0
}
// sanitizeKey converts any value that can be converted to a string into a valid Kubernetes Secret key.
// Valid characters are alphanumeric, -, _, and .
// https://kubernetes.io/docs/concepts/configuration/secret/#restriction-names-data.

View File

@@ -4,33 +4,37 @@
package kubestore
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
)
func TestUpdateStateSecret(t *testing.T) {
func TestWriteState(t *testing.T) {
tests := []struct {
name string
initial map[string][]byte
updates map[string][]byte
key ipn.StateKey
value []byte
wantData map[string][]byte
allowPatch bool
}{
{
name: "basic_update",
name: "basic_write",
initial: map[string][]byte{
"existing": []byte("old"),
},
updates: map[string][]byte{
"foo": []byte("bar"),
},
key: "foo",
value: []byte("bar"),
wantData: map[string][]byte{
"existing": []byte("old"),
"foo": []byte("bar"),
@@ -42,35 +46,17 @@ func TestUpdateStateSecret(t *testing.T) {
initial: map[string][]byte{
"foo": []byte("old"),
},
updates: map[string][]byte{
"foo": []byte("new"),
},
key: "foo",
value: []byte("new"),
wantData: map[string][]byte{
"foo": []byte("new"),
},
allowPatch: true,
},
{
name: "multiple_updates",
initial: map[string][]byte{
"keep": []byte("keep"),
},
updates: map[string][]byte{
"foo": []byte("bar"),
"baz": []byte("qux"),
},
wantData: map[string][]byte{
"keep": []byte("keep"),
"foo": []byte("bar"),
"baz": []byte("qux"),
},
allowPatch: true,
},
{
name: "create_new_secret",
updates: map[string][]byte{
"foo": []byte("bar"),
},
name: "create_new_secret",
key: "foo",
value: []byte("bar"),
wantData: map[string][]byte{
"foo": []byte("bar"),
},
@@ -81,29 +67,23 @@ func TestUpdateStateSecret(t *testing.T) {
initial: map[string][]byte{
"foo": []byte("old"),
},
updates: map[string][]byte{
"foo": []byte("new"),
},
key: "foo",
value: []byte("new"),
wantData: map[string][]byte{
"foo": []byte("new"),
},
allowPatch: false,
},
{
name: "sanitize_keys",
name: "sanitize_key",
initial: map[string][]byte{
"clean-key": []byte("old"),
},
updates: map[string][]byte{
"dirty@key": []byte("new"),
"also/bad": []byte("value"),
"good.key": []byte("keep"),
},
key: "dirty@key",
value: []byte("new"),
wantData: map[string][]byte{
"clean-key": []byte("old"),
"dirty_key": []byte("new"),
"also_bad": []byte("value"),
"good.key": []byte("keep"),
},
allowPatch: true,
},
@@ -152,13 +132,13 @@ func TestUpdateStateSecret(t *testing.T) {
s := &Store{
client: client,
canPatch: tt.allowPatch,
secretName: "test-secret",
secretName: "ts-state",
memory: mem.Store{},
}
err := s.updateStateSecret(tt.updates)
err := s.WriteState(tt.key, tt.value)
if err != nil {
t.Errorf("updateStateSecret() error = %v", err)
t.Errorf("WriteState() error = %v", err)
return
}
@@ -168,16 +148,576 @@ func TestUpdateStateSecret(t *testing.T) {
}
// Verify memory store was updated
for k, v := range tt.updates {
got, err := s.memory.ReadState(ipn.StateKey(sanitizeKey(k)))
got, err := s.memory.ReadState(ipn.StateKey(sanitizeKey(string(tt.key))))
if err != nil {
t.Errorf("reading from memory store: %v", err)
}
if !cmp.Equal(got, tt.value) {
t.Errorf("memory store key %q = %v, want %v", tt.key, got, tt.value)
}
})
}
}
func TestWriteTLSCertAndKey(t *testing.T) {
const (
testDomain = "my-app.tailnetxyz.ts.net"
testCert = "fake-cert"
testKey = "fake-key"
)
tests := []struct {
name string
initial map[string][]byte // pre-existing cert and key
certShareMode string
allowPatch bool // whether client can patch the Secret
wantSecretName string // name of the Secret where cert and key should be written
wantSecretData map[string][]byte
wantMemoryStore map[ipn.StateKey][]byte
}{
{
name: "basic_write",
initial: map[string][]byte{
"existing": []byte("old"),
},
allowPatch: true,
wantSecretName: "ts-state",
wantSecretData: map[string][]byte{
"existing": []byte("old"),
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
wantMemoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
},
{
name: "cert_share_mode_write",
certShareMode: "rw",
allowPatch: true,
wantSecretName: "my-app.tailnetxyz.ts.net",
wantSecretData: map[string][]byte{
"tls.crt": []byte(testCert),
"tls.key": []byte(testKey),
},
wantMemoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
},
{
name: "cert_share_mode_write_update_existing",
initial: map[string][]byte{
"tls.crt": []byte("old-cert"),
"tls.key": []byte("old-key"),
},
certShareMode: "rw",
allowPatch: true,
wantSecretName: "my-app.tailnetxyz.ts.net",
wantSecretData: map[string][]byte{
"tls.crt": []byte(testCert),
"tls.key": []byte(testKey),
},
wantMemoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
},
{
name: "update_existing",
initial: map[string][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte("old-cert"),
"my-app.tailnetxyz.ts.net.key": []byte("old-key"),
},
certShareMode: "",
allowPatch: true,
wantSecretName: "ts-state",
wantSecretData: map[string][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
wantMemoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
},
{
name: "patch_denied",
certShareMode: "",
allowPatch: false,
wantSecretName: "ts-state",
wantSecretData: map[string][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
wantMemoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set POD_NAME for testing selectors
envknob.Setenv("POD_NAME", "ingress-proxies-1")
defer envknob.Setenv("POD_NAME", "")
secret := tt.initial // track current state
client := &kubeclient.FakeClient{
GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
if secret == nil {
return nil, &kubeapi.Status{Code: 404}
}
return &kubeapi.Secret{Data: secret}, nil
},
CheckSecretPermissionsImpl: func(ctx context.Context, name string) (bool, bool, error) {
return tt.allowPatch, true, nil
},
CreateSecretImpl: func(ctx context.Context, s *kubeapi.Secret) error {
if s.Name != tt.wantSecretName {
t.Errorf("CreateSecret called with wrong name, got %q, want %q", s.Name, tt.wantSecretName)
}
secret = s.Data
return nil
},
UpdateSecretImpl: func(ctx context.Context, s *kubeapi.Secret) error {
if s.Name != tt.wantSecretName {
t.Errorf("UpdateSecret called with wrong name, got %q, want %q", s.Name, tt.wantSecretName)
}
secret = s.Data
return nil
},
JSONPatchResourceImpl: func(ctx context.Context, name, resourceType string, patches []kubeclient.JSONPatch) error {
if !tt.allowPatch {
return &kubeapi.Status{Reason: "Forbidden"}
}
if name != tt.wantSecretName {
t.Errorf("JSONPatchResource called with wrong name, got %q, want %q", name, tt.wantSecretName)
}
if secret == nil {
secret = make(map[string][]byte)
}
for _, p := range patches {
if p.Op == "add" && p.Path == "/data" {
secret = p.Value.(map[string][]byte)
} else if p.Op == "add" && strings.HasPrefix(p.Path, "/data/") {
key := strings.TrimPrefix(p.Path, "/data/")
secret[key] = p.Value.([]byte)
}
}
return nil
},
}
s := &Store{
client: client,
canPatch: tt.allowPatch,
secretName: tt.wantSecretName,
certShareMode: tt.certShareMode,
memory: mem.Store{},
}
err := s.WriteTLSCertAndKey(testDomain, []byte(testCert), []byte(testKey))
if err != nil {
t.Errorf("WriteTLSCertAndKey() error = '%v'", err)
return
}
// Verify secret data
if diff := cmp.Diff(secret, tt.wantSecretData); diff != "" {
t.Errorf("secret data mismatch (-got +want):\n%s", diff)
}
// Verify memory store was updated
for key, want := range tt.wantMemoryStore {
got, err := s.memory.ReadState(key)
if err != nil {
t.Errorf("reading from memory store: %v", err)
continue
}
if !cmp.Equal(got, v) {
t.Errorf("memory store key %q = %v, want %v", k, got, v)
if !cmp.Equal(got, want) {
t.Errorf("memory store key %q = %v, want %v", key, got, want)
}
}
})
}
}
func TestReadTLSCertAndKey(t *testing.T) {
const (
testDomain = "my-app.tailnetxyz.ts.net"
testCert = "fake-cert"
testKey = "fake-key"
)
tests := []struct {
name string
memoryStore map[ipn.StateKey][]byte // pre-existing memory store state
certShareMode string
domain string
secretData map[string][]byte // data to return from mock GetSecret
secretGetErr error // error to return from mock GetSecret
wantCert []byte
wantKey []byte
wantErr error
// what should end up in memory store after the store is created
wantMemoryStore map[ipn.StateKey][]byte
}{
{
name: "found",
memoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
domain: testDomain,
wantCert: []byte(testCert),
wantKey: []byte(testKey),
wantMemoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
},
{
name: "not_found",
domain: testDomain,
wantErr: ipn.ErrStateNotExist,
},
{
name: "cert_share_ro_mode_found_in_secret",
certShareMode: "ro",
domain: testDomain,
secretData: map[string][]byte{
"tls.crt": []byte(testCert),
"tls.key": []byte(testKey),
},
wantCert: []byte(testCert),
wantKey: []byte(testKey),
wantMemoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
},
{
name: "cert_share_ro_mode_found_in_memory",
certShareMode: "ro",
memoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
domain: testDomain,
wantCert: []byte(testCert),
wantKey: []byte(testKey),
wantMemoryStore: map[ipn.StateKey][]byte{
"my-app.tailnetxyz.ts.net.crt": []byte(testCert),
"my-app.tailnetxyz.ts.net.key": []byte(testKey),
},
},
{
name: "cert_share_ro_mode_not_found",
certShareMode: "ro",
domain: testDomain,
secretGetErr: &kubeapi.Status{Code: 404},
wantErr: ipn.ErrStateNotExist,
},
{
name: "cert_share_ro_mode_empty_cert_in_secret",
certShareMode: "ro",
domain: testDomain,
secretData: map[string][]byte{
"tls.crt": {},
"tls.key": []byte(testKey),
},
wantErr: ipn.ErrStateNotExist,
},
{
name: "cert_share_ro_mode_kube_api_error",
certShareMode: "ro",
domain: testDomain,
secretGetErr: fmt.Errorf("api error"),
wantErr: fmt.Errorf("getting TLS Secret %q: api error", sanitizeKey(testDomain)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &kubeclient.FakeClient{
GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
if tt.secretGetErr != nil {
return nil, tt.secretGetErr
}
return &kubeapi.Secret{Data: tt.secretData}, nil
},
}
s := &Store{
client: client,
secretName: "ts-state",
certShareMode: tt.certShareMode,
memory: mem.Store{},
}
// Initialize memory store
for k, v := range tt.memoryStore {
s.memory.WriteState(k, v)
}
gotCert, gotKey, err := s.ReadTLSCertAndKey(tt.domain)
if tt.wantErr != nil {
if err == nil {
t.Errorf("ReadTLSCertAndKey() error = nil, want error containing %v", tt.wantErr)
return
}
if !strings.Contains(err.Error(), tt.wantErr.Error()) {
t.Errorf("ReadTLSCertAndKey() error = %v, want error containing %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Errorf("ReadTLSCertAndKey() unexpected error: %v", err)
return
}
if !bytes.Equal(gotCert, tt.wantCert) {
t.Errorf("ReadTLSCertAndKey() gotCert = %v, want %v", gotCert, tt.wantCert)
}
if !bytes.Equal(gotKey, tt.wantKey) {
t.Errorf("ReadTLSCertAndKey() gotKey = %v, want %v", gotKey, tt.wantKey)
}
// Verify memory store contents after operation
if tt.wantMemoryStore != nil {
for key, want := range tt.wantMemoryStore {
got, err := s.memory.ReadState(key)
if err != nil {
t.Errorf("reading from memory store: %v", err)
continue
}
if !bytes.Equal(got, want) {
t.Errorf("memory store key %q = %v, want %v", key, got, want)
}
}
}
})
}
}
func TestNewWithClient(t *testing.T) {
const (
secretName = "ts-state"
testCert = "fake-cert"
testKey = "fake-key"
)
certSecretsLabels := map[string]string{
"tailscale.com/secret-type": "certs",
"tailscale.com/managed": "true",
"tailscale.com/proxy-group": "ingress-proxies",
}
// Helper function to create Secret objects for testing
makeSecret := func(name string, labels map[string]string, certSuffix string) kubeapi.Secret {
return kubeapi.Secret{
ObjectMeta: kubeapi.ObjectMeta{
Name: name,
Labels: labels,
},
Data: map[string][]byte{
"tls.crt": []byte(testCert + certSuffix),
"tls.key": []byte(testKey + certSuffix),
},
}
}
tests := []struct {
name string
stateSecretContents map[string][]byte // data in state Secret
TLSSecrets []kubeapi.Secret // list of TLS cert Secrets
certMode string
secretGetErr error // error to return from GetSecret
secretsListErr error // error to return from ListSecrets
wantMemoryStoreContents map[ipn.StateKey][]byte
wantErr error
}{
{
name: "empty_state_secret",
stateSecretContents: map[string][]byte{},
wantMemoryStoreContents: map[ipn.StateKey][]byte{},
},
{
name: "state_secret_not_found",
secretGetErr: &kubeapi.Status{Code: 404},
wantMemoryStoreContents: map[ipn.StateKey][]byte{},
},
{
name: "state_secret_get_error",
secretGetErr: fmt.Errorf("some error"),
wantErr: fmt.Errorf("error loading state from kube Secret: some error"),
},
{
name: "load_existing_state",
stateSecretContents: map[string][]byte{
"foo": []byte("bar"),
"baz": []byte("qux"),
},
wantMemoryStoreContents: map[ipn.StateKey][]byte{
"foo": []byte("bar"),
"baz": []byte("qux"),
},
},
{
name: "load_select_certs_in_read_only_mode",
certMode: "ro",
stateSecretContents: map[string][]byte{
"foo": []byte("bar"),
},
TLSSecrets: []kubeapi.Secret{
makeSecret("app1.tailnetxyz.ts.net", certSecretsLabels, "1"),
makeSecret("app2.tailnetxyz.ts.net", certSecretsLabels, "2"),
makeSecret("some-other-secret", nil, "3"),
makeSecret("app3.other-proxies.ts.net", map[string]string{
"tailscale.com/secret-type": "certs",
"tailscale.com/managed": "true",
"tailscale.com/proxy-group": "some-other-proxygroup",
}, "4"),
},
wantMemoryStoreContents: map[ipn.StateKey][]byte{
"foo": []byte("bar"),
"app1.tailnetxyz.ts.net.crt": []byte(testCert + "1"),
"app1.tailnetxyz.ts.net.key": []byte(testKey + "1"),
"app2.tailnetxyz.ts.net.crt": []byte(testCert + "2"),
"app2.tailnetxyz.ts.net.key": []byte(testKey + "2"),
},
},
{
name: "load_select_certs_in_read_write_mode",
certMode: "rw",
stateSecretContents: map[string][]byte{
"foo": []byte("bar"),
},
TLSSecrets: []kubeapi.Secret{
makeSecret("app1.tailnetxyz.ts.net", certSecretsLabels, "1"),
makeSecret("app2.tailnetxyz.ts.net", certSecretsLabels, "2"),
makeSecret("some-other-secret", nil, "3"),
makeSecret("app3.other-proxies.ts.net", map[string]string{
"tailscale.com/secret-type": "certs",
"tailscale.com/managed": "true",
"tailscale.com/proxy-group": "some-other-proxygroup",
}, "4"),
},
wantMemoryStoreContents: map[ipn.StateKey][]byte{
"foo": []byte("bar"),
"app1.tailnetxyz.ts.net.crt": []byte(testCert + "1"),
"app1.tailnetxyz.ts.net.key": []byte(testKey + "1"),
"app2.tailnetxyz.ts.net.crt": []byte(testCert + "2"),
"app2.tailnetxyz.ts.net.key": []byte(testKey + "2"),
},
},
{
name: "list_cert_secrets_fails",
certMode: "ro",
stateSecretContents: map[string][]byte{
"foo": []byte("bar"),
},
secretsListErr: fmt.Errorf("list error"),
// The error is logged but not returned, and state is still loaded
wantMemoryStoreContents: map[ipn.StateKey][]byte{
"foo": []byte("bar"),
},
},
{
name: "cert_secrets_not_loaded_when_not_in_share_mode",
certMode: "",
stateSecretContents: map[string][]byte{
"foo": []byte("bar"),
},
TLSSecrets: []kubeapi.Secret{
makeSecret("app1.tailnetxyz.ts.net", certSecretsLabels, "1"),
},
wantMemoryStoreContents: map[ipn.StateKey][]byte{
"foo": []byte("bar"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
envknob.Setenv("TS_CERT_SHARE_MODE", tt.certMode)
t.Setenv("POD_NAME", "ingress-proxies-1")
client := &kubeclient.FakeClient{
GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
if tt.secretGetErr != nil {
return nil, tt.secretGetErr
}
if name == secretName {
return &kubeapi.Secret{Data: tt.stateSecretContents}, nil
}
return nil, &kubeapi.Status{Code: 404}
},
CheckSecretPermissionsImpl: func(ctx context.Context, name string) (bool, bool, error) {
return true, true, nil
},
ListSecretsImpl: func(ctx context.Context, selector map[string]string) (*kubeapi.SecretList, error) {
if tt.secretsListErr != nil {
return nil, tt.secretsListErr
}
var matchingSecrets []kubeapi.Secret
for _, secret := range tt.TLSSecrets {
matches := true
for k, v := range selector {
if secret.Labels[k] != v {
matches = false
break
}
}
if matches {
matchingSecrets = append(matchingSecrets, secret)
}
}
return &kubeapi.SecretList{Items: matchingSecrets}, nil
},
}
s, err := newWithClient(t.Logf, client, secretName)
if tt.wantErr != nil {
if err == nil {
t.Errorf("NewWithClient() error = nil, want error containing %v", tt.wantErr)
return
}
if !strings.Contains(err.Error(), tt.wantErr.Error()) {
t.Errorf("NewWithClient() error = %v, want error containing %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Errorf("NewWithClient() unexpected error: %v", err)
return
}
// Verify memory store contents
gotJSON, err := s.memory.ExportToJSON()
if err != nil {
t.Errorf("ExportToJSON failed: %v", err)
return
}
var got map[ipn.StateKey][]byte
if err := json.Unmarshal(gotJSON, &got); err != nil {
t.Errorf("failed to unmarshal memory store JSON: %v", err)
return
}
want := tt.wantMemoryStoreContents
if want == nil {
want = map[ipn.StateKey][]byte{}
}
if diff := cmp.Diff(got, want); diff != "" {
t.Errorf("memory store contents mismatch (-got +want):\n%s", diff)
}
})
}
}

View File

@@ -517,6 +517,7 @@ _Appears in:_
| `statefulSet` _[StatefulSet](#statefulset)_ | Configuration parameters for the proxy's StatefulSet. Tailscale<br />Kubernetes operator deploys a StatefulSet for each of the user<br />configured proxies (Tailscale Ingress, Tailscale Service, Connector). | | |
| `metrics` _[Metrics](#metrics)_ | Configuration for proxy metrics. Metrics are currently not supported<br />for egress proxies and for Ingress proxies that have been configured<br />with tailscale.com/experimental-forward-cluster-traffic-via-ingress<br />annotation. Note that the metrics are currently considered unstable<br />and will likely change in breaking ways in the future - we only<br />recommend that you use those for debugging purposes. | | |
| `tailscale` _[TailscaleConfig](#tailscaleconfig)_ | TailscaleConfig contains options to configure the tailscale-specific<br />parameters of proxies. | | |
| `useLetsEncryptStagingEnvironment` _boolean_ | Set UseLetsEncryptStagingEnvironment to true to issue TLS<br />certificates for any HTTPS endpoints exposed to the tailnet from<br />LetsEncrypt's staging environment.<br />https://letsencrypt.org/docs/staging-environment/<br />This setting only affects Tailscale Ingress resources.<br />By default Ingress TLS certificates are issued from LetsEncrypt's<br />production environment.<br />Changing this setting true -> false, will result in any<br />existing certs being re-issued from the production environment.<br />Changing this setting false (default) -> true, when certs have already<br />been provisioned from production environment will NOT result in certs<br />being re-issued from the staging environment before they need to be<br />renewed. | | |
#### ProxyClassStatus

View File

@@ -66,6 +66,21 @@ type ProxyClassSpec struct {
// parameters of proxies.
// +optional
TailscaleConfig *TailscaleConfig `json:"tailscale,omitempty"`
// Set UseLetsEncryptStagingEnvironment to true to issue TLS
// certificates for any HTTPS endpoints exposed to the tailnet from
// LetsEncrypt's staging environment.
// https://letsencrypt.org/docs/staging-environment/
// This setting only affects Tailscale Ingress resources.
// By default Ingress TLS certificates are issued from LetsEncrypt's
// production environment.
// Changing this setting true -> false, will result in any
// existing certs being re-issued from the production environment.
// Changing this setting false (default) -> true, when certs have already
// been provisioned from production environment will NOT result in certs
// being re-issued from the staging environment before they need to be
// renewed.
// +optional
UseLetsEncryptStagingEnvironment bool `json:"useLetsEncryptStagingEnvironment,omitempty"`
}
type TailscaleConfig struct {

View File

@@ -153,6 +153,14 @@ type Secret struct {
Data map[string][]byte `json:"data,omitempty"`
}
// SecretList is a list of Secret objects.
type SecretList struct {
TypeMeta `json:",inline"`
ObjectMeta `json:"metadata"`
Items []Secret `json:"items,omitempty"`
}
// Event contains a subset of fields from corev1.Event.
// https://github.com/kubernetes/api/blob/6cc44b8953ae704d6d9ec2adf32e7ae19199ea9f/core/v1/types.go#L7034
// It is copied here to avoid having to import kube libraries.

View File

@@ -60,6 +60,7 @@ func readFile(n string) ([]byte, error) {
// It expects to be run inside a cluster.
type Client interface {
GetSecret(context.Context, string) (*kubeapi.Secret, error)
ListSecrets(context.Context, map[string]string) (*kubeapi.SecretList, error)
UpdateSecret(context.Context, *kubeapi.Secret) error
CreateSecret(context.Context, *kubeapi.Secret) error
// Event attempts to ensure an event with the specified options associated with the Pod in which we are
@@ -248,21 +249,35 @@ func (c *client) newRequest(ctx context.Context, method, url string, in any) (*h
// GetSecret fetches the secret from the Kubernetes API.
func (c *client) GetSecret(ctx context.Context, name string) (*kubeapi.Secret, error) {
s := &kubeapi.Secret{Data: make(map[string][]byte)}
if err := c.kubeAPIRequest(ctx, "GET", c.resourceURL(name, TypeSecrets), nil, s); err != nil {
if err := c.kubeAPIRequest(ctx, "GET", c.resourceURL(name, TypeSecrets, ""), nil, s); err != nil {
return nil, err
}
return s, nil
}
// ListSecrets fetches the secret from the Kubernetes API.
func (c *client) ListSecrets(ctx context.Context, selector map[string]string) (*kubeapi.SecretList, error) {
sl := new(kubeapi.SecretList)
s := make([]string, 0, len(selector))
for key, val := range selector {
s = append(s, key+"="+url.QueryEscape(val))
}
ss := strings.Join(s, ",")
if err := c.kubeAPIRequest(ctx, "GET", c.resourceURL("", TypeSecrets, ss), nil, sl); err != nil {
return nil, err
}
return sl, nil
}
// CreateSecret creates a secret in the Kubernetes API.
func (c *client) CreateSecret(ctx context.Context, s *kubeapi.Secret) error {
s.Namespace = c.ns
return c.kubeAPIRequest(ctx, "POST", c.resourceURL("", TypeSecrets), s, nil)
return c.kubeAPIRequest(ctx, "POST", c.resourceURL("", TypeSecrets, ""), s, nil)
}
// UpdateSecret updates a secret in the Kubernetes API.
func (c *client) UpdateSecret(ctx context.Context, s *kubeapi.Secret) error {
return c.kubeAPIRequest(ctx, "PUT", c.resourceURL(s.Name, TypeSecrets), s, nil)
return c.kubeAPIRequest(ctx, "PUT", c.resourceURL(s.Name, TypeSecrets, ""), s, nil)
}
// JSONPatch is a JSON patch operation.
@@ -283,14 +298,14 @@ func (c *client) JSONPatchResource(ctx context.Context, name, typ string, patche
return fmt.Errorf("unsupported JSON patch operation: %q", p.Op)
}
}
return c.kubeAPIRequest(ctx, "PATCH", c.resourceURL(name, typ), patches, nil, setHeader("Content-Type", "application/json-patch+json"))
return c.kubeAPIRequest(ctx, "PATCH", c.resourceURL(name, typ, ""), patches, nil, setHeader("Content-Type", "application/json-patch+json"))
}
// StrategicMergePatchSecret updates a secret in the Kubernetes API using a
// strategic merge patch.
// If a fieldManager is provided, it will be used to track the patch.
func (c *client) StrategicMergePatchSecret(ctx context.Context, name string, s *kubeapi.Secret, fieldManager string) error {
surl := c.resourceURL(name, TypeSecrets)
surl := c.resourceURL(name, TypeSecrets, "")
if fieldManager != "" {
uv := url.Values{
"fieldManager": {fieldManager},
@@ -342,7 +357,7 @@ func (c *client) Event(ctx context.Context, typ, reason, msg string) error {
LastTimestamp: now,
Count: 1,
}
return c.kubeAPIRequest(ctx, "POST", c.resourceURL("", typeEvents), &ev, nil)
return c.kubeAPIRequest(ctx, "POST", c.resourceURL("", typeEvents, ""), &ev, nil)
}
// If the Event already exists, we patch its count and last timestamp. This ensures that when users run 'kubectl
// describe pod...', they see the event just once (but with a message of how many times it has appeared over
@@ -472,9 +487,13 @@ func (c *client) checkPermission(ctx context.Context, verb, typ, name string) (b
// resourceURL returns a URL that can be used to interact with the given resource type and, if name is not empty string,
// the named resource of that type.
// Note that this only works for core/v1 resource types.
func (c *client) resourceURL(name, typ string) string {
func (c *client) resourceURL(name, typ, sel string) string {
if name == "" {
return fmt.Sprintf("%s/api/v1/namespaces/%s/%s", c.url, c.ns, typ)
url := fmt.Sprintf("%s/api/v1/namespaces/%s/%s", c.url, c.ns, typ)
if sel != "" {
url += "?labelSelector=" + sel
}
return url
}
return fmt.Sprintf("%s/api/v1/namespaces/%s/%s/%s", c.url, c.ns, typ, name)
}
@@ -487,7 +506,7 @@ func (c *client) nameForEvent(reason string) string {
// getEvent fetches the event from the Kubernetes API.
func (c *client) getEvent(ctx context.Context, name string) (*kubeapi.Event, error) {
e := &kubeapi.Event{}
if err := c.kubeAPIRequest(ctx, "GET", c.resourceURL(name, typeEvents), nil, e); err != nil {
if err := c.kubeAPIRequest(ctx, "GET", c.resourceURL(name, typeEvents, ""), nil, e); err != nil {
return nil, err
}
return e, nil

View File

@@ -18,6 +18,7 @@ type FakeClient struct {
CreateSecretImpl func(context.Context, *kubeapi.Secret) error
UpdateSecretImpl func(context.Context, *kubeapi.Secret) error
JSONPatchResourceImpl func(context.Context, string, string, []JSONPatch) error
ListSecretsImpl func(context.Context, map[string]string) (*kubeapi.SecretList, error)
}
func (fc *FakeClient) CheckSecretPermissions(ctx context.Context, name string) (bool, bool, error) {
@@ -45,3 +46,9 @@ func (fc *FakeClient) UpdateSecret(ctx context.Context, secret *kubeapi.Secret)
func (fc *FakeClient) CreateSecret(ctx context.Context, secret *kubeapi.Secret) error {
return fc.CreateSecretImpl(ctx, secret)
}
func (fc *FakeClient) ListSecrets(ctx context.Context, selector map[string]string) (*kubeapi.SecretList, error) {
if fc.ListSecretsImpl != nil {
return fc.ListSecretsImpl(ctx, selector)
}
return nil, nil
}

View File

@@ -48,4 +48,7 @@ const (
PodIPv4Header string = "Pod-IPv4"
EgessServicesPreshutdownEP = "/internal-egress-services-preshutdown"
LabelManaged = "tailscale.com/managed"
LabelSecretType = "tailscale.com/secret-type" // "config", "state" "certs"
)

View File

@@ -627,7 +627,7 @@ func (opts Options) New() *Policy {
conf.IncludeProcSequence = true
}
if envknob.NoLogsNoSupport() || testenv.InTest() {
if envknob.NoLogsNoSupport() || testenv.InTest() || runtime.GOOS == "plan9" {
opts.Logf("You have disabled logging. Tailscale will not be able to provide support.")
conf.HTTPC = &http.Client{Transport: noopPretendSuccessTransport{}}
} else {

View File

@@ -35,6 +35,9 @@ import (
var (
errFullQueue = errors.New("request queue full")
// ErrNoDNSConfig is returned by RecompileDNSConfig when the Manager
// has no existing DNS configuration.
ErrNoDNSConfig = errors.New("no DNS configuration")
)
// maxActiveQueries returns the maximal number of DNS requests that can
@@ -91,21 +94,18 @@ func NewManager(logf logger.Logf, oscfg OSConfigurator, health *health.Tracker,
}
// Rate limit our attempts to correct our DNS configuration.
// This is done on incoming queries, we don't want to spam it.
limiter := rate.NewLimiter(1.0/5.0, 1)
// This will recompile the DNS config, which in turn will requery the system
// DNS settings. The recovery func should triggered only when we are missing
// upstream nameservers and require them to forward a query.
m.resolver.SetMissingUpstreamRecovery(func() {
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
return
}
if limiter.Allow() {
m.logf("DNS resolution failed due to missing upstream nameservers. Recompiling DNS configuration.")
m.setLocked(*m.config)
m.logf("resolution failed due to missing upstream nameservers. Recompiling DNS configuration.")
if err := m.RecompileDNSConfig(); err != nil {
m.logf("config recompilation failed: %v", err)
}
}
})
@@ -117,6 +117,26 @@ func NewManager(logf logger.Logf, oscfg OSConfigurator, health *health.Tracker,
// Resolver returns the Manager's DNS Resolver.
func (m *Manager) Resolver() *resolver.Resolver { return m.resolver }
// RecompileDNSConfig sets the DNS config to the current value, which has
// the side effect of re-querying the OS's interface nameservers. This should be used
// on platforms where the interface nameservers can change. Darwin, for example,
// where the nameservers aren't always available when we process a major interface
// change event, or platforms where the nameservers may change while tunnel is up.
//
// This should be called if it is determined that [OSConfigurator.GetBaseConfig] may
// give a better or different result than when [Manager.Set] was last called. The
// logic for making that determination is up to the caller.
//
// It returns [ErrNoDNSConfig] if the [Manager] has no existing DNS configuration.
func (m *Manager) RecompileDNSConfig() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
return ErrNoDNSConfig
}
return m.setLocked(*m.config)
}
func (m *Manager) Set(cfg Config) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -264,7 +284,7 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
// Deal with trivial configs first.
switch {
case !cfg.needsOSResolver():
case !cfg.needsOSResolver() || runtime.GOOS == "plan9":
// Set search domains, but nothing else. This also covers the
// case where cfg is entirely zero, in which case these
// configs clear all Tailscale DNS settings.

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux && !freebsd && !openbsd && !windows && !darwin && !illumos && !solaris
//go:build !linux && !freebsd && !openbsd && !windows && !darwin && !illumos && !solaris && !plan9
package dns

181
net/dns/manager_plan9.go Normal file
View File

@@ -0,0 +1,181 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// TODO: man 6 ndb | grep -e 'suffix.*same line'
// to detect Russ's https://9fans.topicbox.com/groups/9fans/T9c9d81b5801a0820/ndb-suffix-specific-dns-changes
package dns
import (
"bufio"
"bytes"
"fmt"
"io"
"net/netip"
"os"
"regexp"
"strings"
"unicode"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/types/logger"
"tailscale.com/util/set"
)
func NewOSConfigurator(logf logger.Logf, ht *health.Tracker, knobs *controlknobs.Knobs, interfaceName string) (OSConfigurator, error) {
return &plan9DNSManager{
logf: logf,
ht: ht,
knobs: knobs,
}, nil
}
type plan9DNSManager struct {
logf logger.Logf
ht *health.Tracker
knobs *controlknobs.Knobs
}
// netNDBBytesWithoutTailscale returns raw (the contents of /net/ndb) with any
// Tailscale bits removed.
func netNDBBytesWithoutTailscale(raw []byte) ([]byte, error) {
var ret bytes.Buffer
bs := bufio.NewScanner(bytes.NewReader(raw))
removeLine := set.Set[string]{}
for bs.Scan() {
t := bs.Text()
if rest, ok := strings.CutPrefix(t, "#tailscaled-added-line:"); ok {
removeLine.Add(strings.TrimSpace(rest))
continue
}
trimmed := strings.TrimSpace(t)
if removeLine.Contains(trimmed) {
removeLine.Delete(trimmed)
continue
}
// Also remove any DNS line referencing *.ts.net. This is
// Tailscale-specific (and won't work with, say, Headscale), but
// the Headscale case will be covered by the #tailscaled-added-line
// logic above, assuming the user didn't delete those comments.
if (strings.HasPrefix(trimmed, "dns=") || strings.Contains(trimmed, "dnsdomain=")) &&
strings.HasSuffix(trimmed, ".ts.net") {
continue
}
ret.WriteString(t)
ret.WriteByte('\n')
}
return ret.Bytes(), bs.Err()
}
// setNDBSuffix adds lines to tsFree (the contents of /net/ndb already cleaned
// of Tailscale-added lines) to add the optional DNS search domain (e.g.
// "foo.ts.net") and DNS server to it.
func setNDBSuffix(tsFree []byte, suffix string) []byte {
suffix = strings.TrimSuffix(suffix, ".")
if suffix == "" {
return tsFree
}
var buf bytes.Buffer
bs := bufio.NewScanner(bytes.NewReader(tsFree))
var added []string
addLine := func(s string) {
added = append(added, strings.TrimSpace(s))
buf.WriteString(s)
}
for bs.Scan() {
buf.Write(bs.Bytes())
buf.WriteByte('\n')
t := bs.Text()
if suffix != "" && len(added) == 0 && strings.HasPrefix(t, "\tdns=") {
addLine(fmt.Sprintf("\tdns=100.100.100.100 suffix=%s\n", suffix))
addLine(fmt.Sprintf("\tdnsdomain=%s\n", suffix))
}
}
bufTrim := bytes.TrimLeftFunc(buf.Bytes(), unicode.IsSpace)
if len(added) == 0 {
return bufTrim
}
var ret bytes.Buffer
for _, s := range added {
ret.WriteString("#tailscaled-added-line: ")
ret.WriteString(s)
ret.WriteString("\n")
}
ret.WriteString("\n")
ret.Write(bufTrim)
return ret.Bytes()
}
func (m *plan9DNSManager) SetDNS(c OSConfig) error {
ndbOnDisk, err := os.ReadFile("/net/ndb")
if err != nil {
return err
}
tsFree, err := netNDBBytesWithoutTailscale(ndbOnDisk)
if err != nil {
return err
}
var suffix string
if len(c.SearchDomains) > 0 {
suffix = string(c.SearchDomains[0])
}
newBuf := setNDBSuffix(tsFree, suffix)
if !bytes.Equal(newBuf, ndbOnDisk) {
if err := os.WriteFile("/net/ndb", newBuf, 0644); err != nil {
return fmt.Errorf("writing /net/ndb: %w", err)
}
if f, err := os.OpenFile("/net/dns", os.O_RDWR, 0); err == nil {
if _, err := io.WriteString(f, "refresh\n"); err != nil {
f.Close()
return fmt.Errorf("/net/dns refresh write: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("/net/dns refresh close: %w", err)
}
}
}
return nil
}
func (m *plan9DNSManager) SupportsSplitDNS() bool { return false }
func (m *plan9DNSManager) Close() error {
// TODO(bradfitz): remove the Tailscale bits from /net/ndb ideally
return nil
}
var dnsRegex = regexp.MustCompile(`\bdns=(\d+\.\d+\.\d+\.\d+)\b`)
func (m *plan9DNSManager) GetBaseConfig() (OSConfig, error) {
var oc OSConfig
f, err := os.Open("/net/ndb")
if err != nil {
return oc, err
}
defer f.Close()
bs := bufio.NewScanner(f)
for bs.Scan() {
m := dnsRegex.FindSubmatch(bs.Bytes())
if m == nil {
continue
}
addr, err := netip.ParseAddr(string(m[1]))
if err != nil {
continue
}
oc.Nameservers = append(oc.Nameservers, addr)
}
if err := bs.Err(); err != nil {
return oc, err
}
return oc, nil
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build plan9
package dns
import "testing"
func TestNetNDBBytesWithoutTailscale(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "empty",
raw: "",
want: "",
},
{
name: "no-tailscale",
raw: "# This is a comment\nip=10.0.2.15 ipmask=255.255.255.0 ipgw=10.0.2.2\n\tsys=gnot\n",
want: "# This is a comment\nip=10.0.2.15 ipmask=255.255.255.0 ipgw=10.0.2.2\n\tsys=gnot\n",
},
{
name: "remove-by-comments",
raw: "# This is a comment\n#tailscaled-added-line: dns=100.100.100.100\nip=10.0.2.15 ipmask=255.255.255.0 ipgw=10.0.2.2\n\tdns=100.100.100.100\n\tsys=gnot\n",
want: "# This is a comment\nip=10.0.2.15 ipmask=255.255.255.0 ipgw=10.0.2.2\n\tsys=gnot\n",
},
{
name: "remove-by-ts.net",
raw: "Some line\n\tdns=100.100.100.100 suffix=foo.ts.net\n\tfoo=bar\n",
want: "Some line\n\tfoo=bar\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := netNDBBytesWithoutTailscale([]byte(tt.raw))
if err != nil {
t.Fatal(err)
}
if string(got) != tt.want {
t.Errorf("GOT:\n%s\n\nWANT:\n%s\n", string(got), tt.want)
}
})
}
}
func TestSetNDBSuffix(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "empty",
raw: "",
want: "",
},
{
name: "set",
raw: "ip=10.0.2.15 ipmask=255.255.255.0 ipgw=10.0.2.2\n\tsys=gnot\n\tdns=100.100.100.100\n\n# foo\n",
want: `#tailscaled-added-line: dns=100.100.100.100 suffix=foo.ts.net
#tailscaled-added-line: dnsdomain=foo.ts.net
ip=10.0.2.15 ipmask=255.255.255.0 ipgw=10.0.2.2
sys=gnot
dns=100.100.100.100
dns=100.100.100.100 suffix=foo.ts.net
dnsdomain=foo.ts.net
# foo
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := setNDBSuffix([]byte(tt.raw), "foo.ts.net")
if string(got) != tt.want {
t.Errorf("wrong value\n GOT %q:\n%s\n\nWANT %q:\n%s\n", got, got, tt.want, tt.want)
}
})
}
}

View File

@@ -1188,6 +1188,10 @@ func (c *Client) measureAllICMPLatency(ctx context.Context, rs *reportState, nee
if len(need) == 0 {
return nil
}
if runtime.GOOS == "plan9" {
// ICMP isn't implemented.
return nil
}
ctx, done := context.WithTimeout(ctx, icmpProbeTimeout)
defer done()

View File

@@ -13,7 +13,7 @@ import (
)
func TestGetState(t *testing.T) {
st, err := GetState()
st, err := getState("")
if err != nil {
t.Fatal(err)
}

View File

@@ -161,7 +161,7 @@ func (m *Monitor) InterfaceState() *State {
}
func (m *Monitor) interfaceStateUncached() (*State, error) {
return GetState()
return getState(m.tsIfName)
}
// SetTailscaleInterfaceName sets the name of the Tailscale interface. For
@@ -596,7 +596,7 @@ func (m *Monitor) pollWallTime() {
//
// We don't do this on mobile platforms for battery reasons, and because these
// platforms don't really sleep in the same way.
const shouldMonitorTimeJump = runtime.GOOS != "android" && runtime.GOOS != "ios"
const shouldMonitorTimeJump = runtime.GOOS != "android" && runtime.GOOS != "ios" && runtime.GOOS != "plan9"
// checkWallTimeAdvanceLocked reports whether wall time jumped more than 150% of
// pollWallTimeInterval, indicating we probably just came out of sleep. Once a

View File

@@ -461,21 +461,22 @@ func isTailscaleInterface(name string, ips []netip.Prefix) bool {
// getPAC, if non-nil, returns the current PAC file URL.
var getPAC func() string
// GetState returns the state of all the current machine's network interfaces.
// getState returns the state of all the current machine's network interfaces.
//
// It does not set the returned State.IsExpensive. The caller can populate that.
//
// Deprecated: use netmon.Monitor.InterfaceState instead.
func GetState() (*State, error) {
// optTSInterfaceName is the name of the Tailscale interface, if known.
func getState(optTSInterfaceName string) (*State, error) {
s := &State{
InterfaceIPs: make(map[string][]netip.Prefix),
Interface: make(map[string]Interface),
}
if err := ForeachInterface(func(ni Interface, pfxs []netip.Prefix) {
isTSInterfaceName := optTSInterfaceName != "" && ni.Name == optTSInterfaceName
ifUp := ni.IsUp()
s.Interface[ni.Name] = ni
s.InterfaceIPs[ni.Name] = append(s.InterfaceIPs[ni.Name], pfxs...)
if !ifUp || isTailscaleInterface(ni.Name, pfxs) {
if !ifUp || isTSInterfaceName || isTailscaleInterface(ni.Name, pfxs) {
return
}
for _, pfx := range pfxs {
@@ -755,11 +756,12 @@ func DefaultRoute() (DefaultRouteDetails, error) {
// HasCGNATInterface reports whether there are any non-Tailscale interfaces that
// use a CGNAT IP range.
func HasCGNATInterface() (bool, error) {
func (m *Monitor) HasCGNATInterface() (bool, error) {
hasCGNATInterface := false
cgnatRange := tsaddr.CGNATRange()
err := ForeachInterface(func(i Interface, pfxs []netip.Prefix) {
if hasCGNATInterface || !i.IsUp() || isTailscaleInterface(i.Name, pfxs) {
isTSInterfaceName := m.tsIfName != "" && i.Name == m.tsIfName
if hasCGNATInterface || !i.IsUp() || isTSInterfaceName || isTailscaleInterface(i.Name, pfxs) {
return
}
for _, pfx := range pfxs {

View File

@@ -242,7 +242,7 @@ func changeAffectsConn(delta *netmon.ChangeDelta, conn net.Conn) bool {
// In a few cases, we don't have a new DefaultRouteInterface (e.g. on
// Android; see tailscale/corp#19124); if so, pessimistically assume
// that all connections are affected.
if delta.New.DefaultRouteInterface == "" {
if delta.New.DefaultRouteInterface == "" && runtime.GOOS != "plan9" {
return true
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build plan9 || aix || solaris || illumos
//go:build aix || solaris || illumos
package tstun

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !wasm && !plan9 && !tamago && !aix && !solaris && !illumos
//go:build !wasm && !tamago && !aix && !solaris && !illumos
// Package tun creates a tuntap device, working around OS-specific
// quirks if necessary.
@@ -9,6 +9,9 @@ package tstun
import (
"errors"
"fmt"
"log"
"os"
"runtime"
"strings"
"time"
@@ -45,6 +48,9 @@ func New(logf logger.Logf, tunName string) (tun.Device, string, error) {
}
dev, err = CreateTAP.Get()(logf, tapName, bridgeName)
} else {
if runtime.GOOS == "plan9" {
cleanUpPlan9Interfaces()
}
dev, err = tun.CreateTUN(tunName, int(DefaultTUNMTU()))
}
if err != nil {
@@ -65,6 +71,35 @@ func New(logf logger.Logf, tunName string) (tun.Device, string, error) {
return dev, name, nil
}
func cleanUpPlan9Interfaces() {
maybeUnbind := func(n int) {
b, err := os.ReadFile(fmt.Sprintf("/net/ipifc/%d/status", n))
if err != nil {
return
}
status := string(b)
if !(strings.HasPrefix(status, "device maxtu ") ||
strings.Contains(status, "fd7a:115c:a1e0:")) {
return
}
f, err := os.OpenFile(fmt.Sprintf("/net/ipifc/%d/ctl", n), os.O_RDWR, 0)
if err != nil {
return
}
defer f.Close()
if _, err := fmt.Fprintf(f, "unbind\n"); err != nil {
log.Printf("unbind interface %v: %v", n, err)
return
}
log.Printf("tun: unbound stale interface %v", n)
}
// A common case: after unclean shutdown, the /net/ipifc/clone file
for n := 2; n < 5; n++ {
maybeUnbind(n)
}
}
// tunDiagnoseFailure, if non-nil, does OS-specific diagnostics of why
// TUN failed to work.
var tunDiagnoseFailure func(tunName string, logf logger.Logf, err error)

View File

@@ -928,8 +928,10 @@ func (t *Wrapper) Read(buffs [][]byte, sizes []int, offset int) (int, error) {
// packet from OS read and sent to WG
res, ok := <-t.vectorOutbound
if !ok {
t.logf("XXX Wrapper.vectorInbound done")
return 0, io.EOF
}
// t.logf("XXX Wrapper.vec in: err=%v, len(data)=%d, offset=%d", res.err, len(res.data), offset)
if res.err != nil && len(res.data) == 0 {
return 0, res.err
}
@@ -947,6 +949,7 @@ func (t *Wrapper) Read(buffs [][]byte, sizes []int, offset int) (int, error) {
var buffsGRO *gro.GRO
for _, data := range res.data {
p.Decode(data[res.dataOffset:])
// t.logf("XXX Wrapper.Read decode (off=%d): %v", res.dataOffset, p.String())
if m := t.destIPActivity.Load(); m != nil {
if fn := m[p.Dst.Addr()]; fn != nil {

View File

@@ -26,6 +26,9 @@ func TestPackageDocs(t *testing.T) {
if err != nil {
return err
}
if fi.Mode().IsDir() && path == ".git" {
return filepath.SkipDir // No documentation lives in .git
}
if fi.Mode().IsRegular() && strings.HasSuffix(path, ".go") {
if strings.HasSuffix(path, "_test.go") {
return nil

122
portlist/portlist_plan9.go Normal file
View File

@@ -0,0 +1,122 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package portlist
import (
"bufio"
"bytes"
"os"
"strconv"
"strings"
"time"
)
func init() {
newOSImpl = newPlan9Impl
pollInterval = 5 * time.Second
}
type plan9Impl struct {
known map[protoPort]*portMeta // inode string => metadata
br *bufio.Reader // reused
portsBuf []Port
includeLocalhost bool
}
type protoPort struct {
proto string
port uint16
}
type portMeta struct {
port Port
keep bool
}
func newPlan9Impl(includeLocalhost bool) osImpl {
return &plan9Impl{
known: map[protoPort]*portMeta{},
br: bufio.NewReader(bytes.NewReader(nil)),
includeLocalhost: includeLocalhost,
}
}
func (*plan9Impl) Close() error { return nil }
func (im *plan9Impl) AppendListeningPorts(base []Port) ([]Port, error) {
ret := base
des, err := os.ReadDir("/proc")
if err != nil {
return nil, err
}
for _, de := range des {
if !de.IsDir() {
continue
}
pidStr := de.Name()
pid, err := strconv.Atoi(pidStr)
if err != nil {
continue
}
st, _ := os.ReadFile("/proc/" + pidStr + "/fd")
if !bytes.Contains(st, []byte("/net/tcp/clone")) {
continue
}
args, _ := os.ReadFile("/proc/" + pidStr + "/args")
procName := string(bytes.TrimSpace(args))
// term% cat /proc/417/fd
// /usr/glenda
// 0 r M 35 (0000000000000001 0 00) 16384 260 /dev/cons
// 1 w c 0 (000000000000000a 0 00) 0 471 /dev/null
// 2 w M 35 (0000000000000001 0 00) 16384 108 /dev/cons
// 3 rw I 0 (000000000000002c 0 00) 0 14 /net/tcp/clone
for line := range bytes.Lines(st) {
if !bytes.Contains(line, []byte("/net/tcp/clone")) {
continue
}
f := strings.Fields(string(line))
if len(f) < 10 {
continue
}
if f[9] != "/net/tcp/clone" {
continue
}
qid, err := strconv.ParseUint(strings.TrimPrefix(f[4], "("), 16, 64)
if err != nil {
continue
}
tcpN := (qid >> 5) & (1<<12 - 1)
tcpNStr := strconv.FormatUint(tcpN, 10)
st, _ := os.ReadFile("/net/tcp/" + tcpNStr + "/status")
if !bytes.Contains(st, []byte("Listen ")) {
// Unexpected. Or a race.
continue
}
bl, _ := os.ReadFile("/net/tcp/" + tcpNStr + "/local")
i := bytes.LastIndexByte(bl, '!')
if i == -1 {
continue
}
if bytes.HasPrefix(bl, []byte("127.0.0.1!")) && !im.includeLocalhost {
continue
}
portStr := strings.TrimSpace(string(bl[i+1:]))
port, _ := strconv.Atoi(portStr)
if port == 0 {
continue
}
ret = append(ret, Port{
Proto: "tcp",
Port: uint16(port),
Process: procName,
Pid: pid,
})
}
}
return ret, nil
}

View File

@@ -61,7 +61,11 @@ func ConnectContext(ctx context.Context, path string) (net.Conn, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
time.Sleep(250 * time.Millisecond)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(250 * time.Millisecond):
}
continue
}
return c, err

View File

@@ -7,119 +7,13 @@ package safesocket
import (
"context"
"fmt"
"net"
"os"
"syscall"
"time"
"golang.org/x/sys/plan9"
)
// Plan 9's devsrv srv(3) is a server registry and
// it is conventionally bound to "/srv" in the default
// namespace. It is "a one level directory for holding
// already open channels to services". Post one end of
// a pipe to "/srv/tailscale.sock" and use the other
// end for communication with a requestor. Plan 9 pipes
// are bidirectional.
type plan9SrvAddr string
func (sl plan9SrvAddr) Network() string {
return "/srv"
}
func (sl plan9SrvAddr) String() string {
return string(sl)
}
// There is no net.FileListener for Plan 9 at this time
type plan9SrvListener struct {
name string
srvf *os.File
file *os.File
}
func (sl *plan9SrvListener) Accept() (net.Conn, error) {
// sl.file is the server end of the pipe that's
// connected to /srv/tailscale.sock
return plan9FileConn{name: sl.name, file: sl.file}, nil
}
func (sl *plan9SrvListener) Close() error {
sl.file.Close()
return sl.srvf.Close()
}
func (sl *plan9SrvListener) Addr() net.Addr {
return plan9SrvAddr(sl.name)
}
type plan9FileConn struct {
name string
file *os.File
}
func (fc plan9FileConn) Read(b []byte) (n int, err error) {
return fc.file.Read(b)
}
func (fc plan9FileConn) Write(b []byte) (n int, err error) {
return fc.file.Write(b)
}
func (fc plan9FileConn) Close() error {
return fc.file.Close()
}
func (fc plan9FileConn) LocalAddr() net.Addr {
return plan9SrvAddr(fc.name)
}
func (fc plan9FileConn) RemoteAddr() net.Addr {
return plan9SrvAddr(fc.name)
}
func (fc plan9FileConn) SetDeadline(t time.Time) error {
return syscall.EPLAN9
}
func (fc plan9FileConn) SetReadDeadline(t time.Time) error {
return syscall.EPLAN9
}
func (fc plan9FileConn) SetWriteDeadline(t time.Time) error {
return syscall.EPLAN9
}
func connect(_ context.Context, path string) (net.Conn, error) {
f, err := os.OpenFile(path, os.O_RDWR, 0666)
if err != nil {
return nil, err
}
return plan9FileConn{name: path, file: f}, nil
return net.Dial("tcp", "localhost:5252")
}
// Create an entry in /srv, open a pipe, write the
// client end to the entry and return the server
// end of the pipe to the caller. When the server
// end of the pipe is closed, /srv name associated
// with it will be removed (controlled by ORCLOSE flag)
func listen(path string) (net.Listener, error) {
const O_RCLOSE = 64 // remove on close; should be in plan9 package
var pip [2]int
err := plan9.Pipe(pip[:])
if err != nil {
return nil, err
}
defer plan9.Close(pip[1])
srvfd, err := plan9.Create(path, plan9.O_WRONLY|plan9.O_CLOEXEC|O_RCLOSE, 0600)
if err != nil {
return nil, err
}
srv := os.NewFile(uintptr(srvfd), path)
_, err = fmt.Fprintf(srv, "%d", pip[1])
if err != nil {
return nil, err
}
return &plan9SrvListener{name: path, srvf: srv, file: os.NewFile(uintptr(pip[0]), path)}, nil
return net.Listen("tcp", "localhost:5252")
}

View File

@@ -0,0 +1,421 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// This file contains the plan9-specific version of the incubator. Tailscaled
// launches the incubator as the same user as it was launched as. The
// incubator then registers a new session with the OS, sets its UID
// and groups to the specified `--uid`, `--gid` and `--groups`, and
// then launches the requested `--cmd`.
package tailssh
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync/atomic"
"github.com/go4org/plan9netshell"
"github.com/pkg/sftp"
"tailscale.com/cmd/tailscaled/childproc"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
)
func init() {
childproc.Add("ssh", beIncubator)
childproc.Add("sftp", beSFTP)
childproc.Add("plan9-netshell", beNetshell)
}
// newIncubatorCommand returns a new exec.Cmd configured with
// `tailscaled be-child ssh` as the entrypoint.
//
// If ss.srv.tailscaledPath is empty, this method is equivalent to
// exec.CommandContext.
//
// The returned Cmd.Env is guaranteed to be nil; the caller populates it.
func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err error) {
defer func() {
if cmd.Env != nil {
panic("internal error")
}
}()
var isSFTP, isShell bool
switch ss.Subsystem() {
case "sftp":
isSFTP = true
case "":
isShell = ss.RawCommand() == ""
default:
panic(fmt.Sprintf("unexpected subsystem: %v", ss.Subsystem()))
}
if ss.conn.srv.tailscaledPath == "" {
if isSFTP {
// SFTP relies on the embedded Go-based SFTP server in tailscaled,
// so without tailscaled, we can't serve SFTP.
return nil, errors.New("no tailscaled found on path, can't serve SFTP")
}
loginShell := ss.conn.localUser.LoginShell()
logf("directly running /bin/rc -c %q", ss.RawCommand())
return exec.CommandContext(ss.ctx, loginShell, "-c", ss.RawCommand()), nil
}
lu := ss.conn.localUser
ci := ss.conn.info
remoteUser := ci.uprof.LoginName
if ci.node.IsTagged() {
remoteUser = strings.Join(ci.node.Tags().AsSlice(), ",")
}
incubatorArgs := []string{
"be-child",
"ssh",
// TODO: "--uid=" + lu.Uid,
// TODO: "--gid=" + lu.Gid,
"--local-user=" + lu.Username,
"--home-dir=" + lu.HomeDir,
"--remote-user=" + remoteUser,
"--remote-ip=" + ci.src.Addr().String(),
"--has-tty=false", // updated in-place by startWithPTY
"--tty-name=", // updated in-place by startWithPTY
}
nm := ss.conn.srv.lb.NetMap()
forceV1Behavior := nm.HasCap(tailcfg.NodeAttrSSHBehaviorV1) && !nm.HasCap(tailcfg.NodeAttrSSHBehaviorV2)
if forceV1Behavior {
incubatorArgs = append(incubatorArgs, "--force-v1-behavior")
}
if debugTest.Load() {
incubatorArgs = append(incubatorArgs, "--debug-test")
}
switch {
case isSFTP:
// Note that we include both the `--sftp` flag and a command to launch
// tailscaled as `be-child sftp`. If login or su is available, and
// we're not running with tailcfg.NodeAttrSSHBehaviorV1, this will
// result in serving SFTP within a login shell, with full PAM
// integration. Otherwise, we'll serve SFTP in the incubator process
// with no PAM integration.
incubatorArgs = append(incubatorArgs, "--sftp", fmt.Sprintf("--cmd=%s be-child sftp", ss.conn.srv.tailscaledPath))
case isShell:
incubatorArgs = append(incubatorArgs, "--shell")
default:
incubatorArgs = append(incubatorArgs, "--cmd="+ss.RawCommand())
}
allowSendEnv := nm.HasCap(tailcfg.NodeAttrSSHEnvironmentVariables)
if allowSendEnv {
env, err := filterEnv(ss.conn.acceptEnv, ss.Session.Environ())
if err != nil {
return nil, err
}
if len(env) > 0 {
encoded, err := json.Marshal(env)
if err != nil {
return nil, fmt.Errorf("failed to encode environment: %w", err)
}
incubatorArgs = append(incubatorArgs, fmt.Sprintf("--encoded-env=%q", encoded))
}
}
return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...), nil
}
var debugTest atomic.Bool
type stdRWC struct{}
func (stdRWC) Read(p []byte) (n int, err error) {
return os.Stdin.Read(p)
}
func (stdRWC) Write(b []byte) (n int, err error) {
return os.Stdout.Write(b)
}
func (stdRWC) Close() error {
os.Exit(0)
return nil
}
type incubatorArgs struct {
localUser string
homeDir string
remoteUser string
remoteIP string
ttyName string
hasTTY bool
cmd string
isSFTP bool
isShell bool
forceV1Behavior bool
debugTest bool
isSELinuxEnforcing bool
encodedEnv string
}
func parseIncubatorArgs(args []string) (incubatorArgs, error) {
var ia incubatorArgs
flags := flag.NewFlagSet("", flag.ExitOnError)
flags.StringVar(&ia.localUser, "local-user", "", "the user to run as")
flags.StringVar(&ia.homeDir, "home-dir", "/", "the user's home directory")
flags.StringVar(&ia.remoteUser, "remote-user", "", "the remote user/tags")
flags.StringVar(&ia.remoteIP, "remote-ip", "", "the remote Tailscale IP")
flags.StringVar(&ia.ttyName, "tty-name", "", "the tty name (pts/3)")
flags.BoolVar(&ia.hasTTY, "has-tty", false, "is the output attached to a tty")
flags.StringVar(&ia.cmd, "cmd", "", "the cmd to launch, including all arguments (ignored in sftp mode)")
flags.BoolVar(&ia.isShell, "shell", false, "is launching a shell (with no cmds)")
flags.BoolVar(&ia.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
flags.BoolVar(&ia.forceV1Behavior, "force-v1-behavior", false, "allow falling back to the su command if login is unavailable")
flags.BoolVar(&ia.debugTest, "debug-test", false, "should debug in test mode")
flags.BoolVar(&ia.isSELinuxEnforcing, "is-selinux-enforcing", false, "whether SELinux is in enforcing mode")
flags.StringVar(&ia.encodedEnv, "encoded-env", "", "JSON encoded array of environment variables in '['key=value']' format")
flags.Parse(args)
return ia, nil
}
func (ia incubatorArgs) forwardedEnviron() ([]string, string, error) {
environ := os.Environ()
// pass through SSH_AUTH_SOCK environment variable to support ssh agent forwarding
allowListKeys := "SSH_AUTH_SOCK"
if ia.encodedEnv != "" {
unquoted, err := strconv.Unquote(ia.encodedEnv)
if err != nil {
return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
}
var extraEnviron []string
err = json.Unmarshal([]byte(unquoted), &extraEnviron)
if err != nil {
return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
}
environ = append(environ, extraEnviron...)
for _, v := range extraEnviron {
allowListKeys = fmt.Sprintf("%s,%s", allowListKeys, strings.Split(v, "=")[0])
}
}
return environ, allowListKeys, nil
}
func beNetshell(args []string) error {
plan9netshell.Main()
return nil
}
// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand.
// It is responsible for informing the system of a new login session for the
// user. This is sometimes necessary for mounting home directories and
// decrypting file systems.
//
// Tailscaled launches the incubator as the same user as it was launched as.
func beIncubator(args []string) error {
// To defend against issues like https://golang.org/issue/1435,
// defensively lock our current goroutine's thread to the current
// system thread before we start making any UID/GID/group changes.
//
// This shouldn't matter on Linux because syscall.AllThreadsSyscall is
// used to invoke syscalls on all OS threads, but (as of 2023-03-23)
// that function is not implemented on all platforms.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
ia, err := parseIncubatorArgs(args)
if err != nil {
return err
}
if ia.isSFTP && ia.isShell {
return fmt.Errorf("--sftp and --shell are mutually exclusive")
}
if ia.isShell {
plan9netshell.Main()
return nil
}
dlogf := logger.Discard
if ia.debugTest {
// In testing, we don't always have syslog, so log to a temp file.
if logFile, err := os.OpenFile("/tmp/tailscalessh.log", os.O_APPEND|os.O_WRONLY, 0666); err == nil {
lf := log.New(logFile, "", 0)
dlogf = func(msg string, args ...any) {
lf.Printf(msg, args...)
logFile.Sync()
}
defer logFile.Close()
}
}
return handleInProcess(dlogf, ia)
}
func handleInProcess(dlogf logger.Logf, ia incubatorArgs) error {
if ia.isSFTP {
return handleSFTPInProcess(dlogf, ia)
}
return handleSSHInProcess(dlogf, ia)
}
func handleSFTPInProcess(dlogf logger.Logf, ia incubatorArgs) error {
dlogf("handling sftp")
return serveSFTP()
}
// beSFTP serves SFTP in-process.
func beSFTP(args []string) error {
return serveSFTP()
}
func serveSFTP() error {
server, err := sftp.NewServer(stdRWC{})
if err != nil {
return err
}
// TODO(https://github.com/pkg/sftp/pull/554): Revert the check for io.EOF,
// when sftp is patched to report clean termination.
if err := server.Serve(); err != nil && err != io.EOF {
return err
}
return nil
}
// handleSSHInProcess is a last resort if we couldn't use login or su. It
// registers a new session with the OS, sets its UID, GID and groups to the
// specified values, and then launches the requested `--cmd` in the user's
// login shell.
func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error {
environ, _, err := ia.forwardedEnviron()
if err != nil {
return err
}
dlogf("running /bin/rc -c %q", ia.cmd)
cmd := newCommand("/bin/rc", environ, []string{"-c", ia.cmd})
err = cmd.Run()
if ee, ok := err.(*exec.ExitError); ok {
ps := ee.ProcessState
code := ps.ExitCode()
if code < 0 {
// TODO(bradfitz): do we need to also check the syscall.WaitStatus
// and make our process look like it also died by signal/same signal
// as our child process? For now we just do the exit code.
fmt.Fprintf(os.Stderr, "[tailscale-ssh: process died: %v]\n", ps.String())
code = 1 // for now. so we don't exit with negative
}
os.Exit(code)
}
return err
}
func newCommand(cmdPath string, cmdEnviron []string, cmdArgs []string) *exec.Cmd {
cmd := exec.Command(cmdPath, cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = cmdEnviron
return cmd
}
// launchProcess launches an incubator process for the provided session.
// It is responsible for configuring the process execution environment.
// The caller can wait for the process to exit by calling cmd.Wait().
//
// It sets ss.cmd, stdin, stdout, and stderr.
func (ss *sshSession) launchProcess() error {
var err error
ss.cmd, err = ss.newIncubatorCommand(ss.logf)
if err != nil {
return err
}
cmd := ss.cmd
cmd.Dir = "/"
cmd.Env = append(os.Environ(), envForUser(ss.conn.localUser)...)
for _, kv := range ss.Environ() {
if acceptEnvPair(kv) {
cmd.Env = append(cmd.Env, kv)
}
}
ci := ss.conn.info
cmd.Env = append(cmd.Env,
fmt.Sprintf("SSH_CLIENT=%s %d %d", ci.src.Addr(), ci.src.Port(), ci.dst.Port()),
fmt.Sprintf("SSH_CONNECTION=%s %d %s %d", ci.src.Addr(), ci.src.Port(), ci.dst.Addr(), ci.dst.Port()),
)
if ss.agentListener != nil {
cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_AUTH_SOCK=%s", ss.agentListener.Addr()))
}
return ss.startWithStdPipes()
}
// startWithStdPipes starts cmd with os.Pipe for Stdin, Stdout and Stderr.
func (ss *sshSession) startWithStdPipes() (err error) {
var rdStdin, wrStdout, wrStderr io.ReadWriteCloser
defer func() {
if err != nil {
closeAll(rdStdin, ss.wrStdin, ss.rdStdout, wrStdout, ss.rdStderr, wrStderr)
}
}()
if ss.cmd == nil {
return errors.New("nil cmd")
}
if rdStdin, ss.wrStdin, err = os.Pipe(); err != nil {
return err
}
if ss.rdStdout, wrStdout, err = os.Pipe(); err != nil {
return err
}
if ss.rdStderr, wrStderr, err = os.Pipe(); err != nil {
return err
}
ss.cmd.Stdin = rdStdin
ss.cmd.Stdout = wrStdout
ss.cmd.Stderr = wrStderr
ss.childPipes = []io.Closer{rdStdin, wrStdout, wrStderr}
return ss.cmd.Start()
}
func envForUser(u *userMeta) []string {
return []string{
fmt.Sprintf("user=%s", u.Username),
fmt.Sprintf("home=%s", u.HomeDir),
fmt.Sprintf("path=%s", defaultPathForUser(&u.User)),
}
}
// acceptEnvPair reports whether the environment variable key=value pair
// should be accepted from the client. It uses the same default as OpenSSH
// AcceptEnv.
func acceptEnvPair(kv string) bool {
k, _, ok := strings.Cut(kv, "=")
if !ok {
return false
}
_ = k
return true // permit anything on plan9 during bringup, for debugging at least
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || (darwin && !ios) || freebsd || openbsd
//go:build linux || (darwin && !ios) || freebsd || openbsd || plan9
// Package tailssh is an SSH server integrated into Tailscale.
package tailssh
@@ -672,7 +672,6 @@ type sshSession struct {
wrStdin io.WriteCloser
rdStdout io.ReadCloser
rdStderr io.ReadCloser // rdStderr is nil for pty sessions
ptyReq *ssh.Pty // non-nil for pty sessions
// childPipes is a list of pipes that need to be closed when the process exits.
// For pty sessions, this is the tty fd.
@@ -903,7 +902,7 @@ func (ss *sshSession) run() {
defer t.Stop()
}
if euid := os.Geteuid(); euid != 0 {
if euid := os.Geteuid(); euid != 0 && runtime.GOOS != "plan9" {
if lu.Uid != fmt.Sprint(euid) {
ss.logf("can't switch to user %q from process euid %v", lu.Username, euid)
fmt.Fprintf(ss, "can't switch user\r\n")

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || (darwin && !ios) || freebsd || openbsd
//go:build linux || (darwin && !ios) || freebsd || openbsd || plan9
package tailssh
@@ -48,6 +48,9 @@ func userLookup(username string) (*userMeta, error) {
}
func (u *userMeta) LoginShell() string {
if runtime.GOOS == "plan9" {
return "/bin/rc"
}
if u.loginShellCached != "" {
// This field should be populated on Linux, at least, because
// func userLookup on Linux uses "getent" to look up the user
@@ -85,6 +88,9 @@ func defaultPathForUser(u *user.User) string {
if s := defaultPathTmpl(); s != "" {
return expandDefaultPathTmpl(s, u)
}
if runtime.GOOS == "plan9" {
return "/bin"
}
isRoot := u.Uid == "0"
switch distro.Get() {
case distro.Debian:

View File

@@ -120,6 +120,7 @@ func startControl(t *testing.T) (controlURL string, control *testcontrol.Server)
Proxied: true,
},
MagicDNSDomain: "tail-scale.ts.net",
Logf: t.Logf,
}
control.HTTPTestServer = httptest.NewUnstartedServer(control)
control.HTTPTestServer.Start()
@@ -221,7 +222,7 @@ func startServer(t *testing.T, ctx context.Context, controlURL, hostname string)
getCertForTesting: testCertRoot.getCert,
}
if *verboseNodes {
s.Logf = log.Printf
s.Logf = t.Logf
}
t.Cleanup(func() { s.Close() })

View File

@@ -1942,6 +1942,8 @@ func (n *testNode) AwaitIP6() netip.Addr {
// AwaitRunning waits for n to reach the IPN state "Running".
func (n *testNode) AwaitRunning() {
t := n.env.t
t.Helper()
n.AwaitBackendState("Running")
}
@@ -2015,7 +2017,7 @@ func (n *testNode) Status() (*ipnstate.Status, error) {
}
st := new(ipnstate.Status)
if err := json.Unmarshal(out, st); err != nil {
return nil, fmt.Errorf("decoding tailscale status JSON: %w", err)
return nil, fmt.Errorf("decoding tailscale status JSON: %w\njson:\n%s", err, out)
}
return st, nil
}

599
tstest/mts/mts.go Normal file
View File

@@ -0,0 +1,599 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || darwin
// The mts ("Multiple Tailscale") command runs multiple tailscaled instances for
// development, managing their directories and sockets, and lets you easily direct
// tailscale CLI commands to them.
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"maps"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"syscall"
"time"
"tailscale.com/client/local"
"tailscale.com/types/bools"
"tailscale.com/types/lazy"
"tailscale.com/util/mak"
)
func usage(args ...any) {
var format string
if len(args) > 0 {
format, args = args[0].(string), args[1:]
}
if format != "" {
format = strings.TrimSpace(format) + "\n\n"
fmt.Fprintf(os.Stderr, format, args...)
}
io.WriteString(os.Stderr, strings.TrimSpace(`
usage:
mts server <subcommand> # manage tailscaled instances
mts server run # run the mts server (parent process of all tailscaled)
mts server list # list all tailscaled and their state
mts server list <name> # show details of named instance
mts server add <name> # add+start new named tailscaled
mts server start <name> # start a previously added tailscaled
mts server stop <name> # stop & remove a named tailscaled
mts server rm <name> # stop & remove a named tailscaled
mts server logs [-f] <name> # get/follow tailscaled logs
mts <inst-name> [tailscale CLI args] # run Tailscale CLI against a named instance
e.g.
mts gmail1 up
mts github2 status --json
`)+"\n")
os.Exit(1)
}
func main() {
// Don't use flag.Parse here; we mostly just delegate through
// to the Tailscale CLI.
if len(os.Args) < 2 {
usage()
}
firstArg, args := os.Args[1], os.Args[2:]
if firstArg == "server" || firstArg == "s" {
if err := runMTSServer(args); err != nil {
log.Fatal(err)
}
} else {
var c Client
inst := firstArg
c.RunCommand(inst, args)
}
}
func runMTSServer(args []string) error {
if len(args) == 0 {
usage()
}
cmd, args := args[0], args[1:]
if cmd == "run" {
var s Server
return s.Run()
}
// Commands other than "run" all use the HTTP client to
// hit the mts server over its unix socket.
var c Client
switch cmd {
default:
usage("unknown mts server subcommand %q", cmd)
case "list", "ls":
list, err := c.List()
if err != nil {
return err
}
if len(args) == 0 {
names := slices.Sorted(maps.Keys(list.Instances))
for _, name := range names {
running := list.Instances[name].Running
fmt.Printf("%10s %s\n", bools.IfElse(running, "RUNNING", "stopped"), name)
}
} else {
for _, name := range args {
inst, ok := list.Instances[name]
if !ok {
return fmt.Errorf("no instance named %q", name)
}
je := json.NewEncoder(os.Stdout)
je.SetIndent("", " ")
if err := je.Encode(inst); err != nil {
return err
}
}
}
case "rm":
if len(args) == 0 {
return fmt.Errorf("missing instance name(s) to remove")
}
log.SetFlags(0)
for _, name := range args {
ok, err := c.Remove(name)
if err != nil {
return err
}
if ok {
log.Printf("%s deleted.", name)
} else {
log.Printf("%s didn't exist.", name)
}
}
case "stop":
if len(args) == 0 {
return fmt.Errorf("missing instance name(s) to stop")
}
log.SetFlags(0)
for _, name := range args {
ok, err := c.Stop(name)
if err != nil {
return err
}
if ok {
log.Printf("%s stopped.", name)
} else {
log.Printf("%s didn't exist.", name)
}
}
case "start", "restart":
list, err := c.List()
if err != nil {
return err
}
shouldStop := cmd == "restart"
for _, arg := range args {
is, ok := list.Instances[arg]
if !ok {
return fmt.Errorf("no instance named %q", arg)
}
if is.Running {
if shouldStop {
if _, err := c.Stop(arg); err != nil {
return fmt.Errorf("stopping %q: %w", arg, err)
}
} else {
log.SetFlags(0)
log.Printf("%s already running.", arg)
continue
}
}
// Creating an existing one starts it up.
if err := c.Create(arg); err != nil {
return fmt.Errorf("starting %q: %w", arg, err)
}
}
case "add":
if len(args) == 0 {
return fmt.Errorf("missing instance name(s) to add")
}
for _, name := range args {
if err := c.Create(name); err != nil {
return fmt.Errorf("creating %q: %w", name, err)
}
}
case "logs":
fs := flag.NewFlagSet("logs", flag.ExitOnError)
fs.Usage = func() { usage() }
follow := fs.Bool("f", false, "follow logs")
fs.Parse(args)
log.Printf("Parsed; following=%v, args=%q", *follow, fs.Args())
if fs.NArg() != 1 {
usage()
}
cmd := bools.IfElse(*follow, "tail", "cat")
args := []string{cmd}
if *follow {
args = append(args, "-f")
}
path, err := exec.LookPath(cmd)
if err != nil {
return fmt.Errorf("looking up %q: %w", cmd, err)
}
args = append(args, instLogsFile(fs.Arg(0)))
log.Fatal(syscall.Exec(path, args, os.Environ()))
}
return nil
}
type Client struct {
}
func (c *Client) client() *http.Client {
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial("unix", mtsSock())
},
},
}
}
func getJSON[T any](res *http.Response, err error) (T, error) {
var ret T
if err != nil {
return ret, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
return ret, fmt.Errorf("unexpected status: %v: %s", res.Status, body)
}
if err := json.NewDecoder(res.Body).Decode(&ret); err != nil {
return ret, err
}
return ret, nil
}
func (c *Client) List() (listResponse, error) {
return getJSON[listResponse](c.client().Get("http://mts/list"))
}
func (c *Client) Remove(name string) (found bool, err error) {
return getJSON[bool](c.client().PostForm("http://mts/rm", url.Values{
"name": []string{name},
}))
}
func (c *Client) Stop(name string) (found bool, err error) {
return getJSON[bool](c.client().PostForm("http://mts/stop", url.Values{
"name": []string{name},
}))
}
func (c *Client) Create(name string) error {
req, err := http.NewRequest("POST", "http://mts/create/"+name, nil)
if err != nil {
return err
}
resp, err := c.client().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status: %v: %s", resp.Status, body)
}
return nil
}
func (c *Client) RunCommand(name string, args []string) {
sock := instSock(name)
lc := &local.Client{
Socket: sock,
UseSocketOnly: true,
}
probeCtx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()
if _, err := lc.StatusWithoutPeers(probeCtx); err != nil {
log.Fatalf("instance %q not running? start with 'mts server start %q'; got error: %v", name, name, err)
}
args = append([]string{"run", "tailscale.com/cmd/tailscale", "--socket=" + sock}, args...)
cmd := exec.Command("go", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err := cmd.Run()
if err == nil {
os.Exit(0)
}
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
panic(err)
}
type Server struct {
lazyTailscaled lazy.GValue[string]
mu sync.Mutex
cmds map[string]*exec.Cmd // running tailscaled instances
}
func (s *Server) tailscaled() string {
v, err := s.lazyTailscaled.GetErr(func() (string, error) {
out, err := exec.Command("go", "list", "-f", "{{.Target}}", "tailscale.com/cmd/tailscaled").CombinedOutput()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
})
if err != nil {
panic(err)
}
return v
}
func (s *Server) Run() error {
if err := os.MkdirAll(mtsRoot(), 0700); err != nil {
return err
}
sock := mtsSock()
os.Remove(sock)
log.Printf("Multi-Tailscaled Server running; listening on %q ...", sock)
ln, err := net.Listen("unix", sock)
if err != nil {
return err
}
return http.Serve(ln, s)
}
var validNameRx = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
func validInstanceName(name string) bool {
return validNameRx.MatchString(name)
}
func (s *Server) InstanceRunning(name string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.cmds[name]
return ok
}
func (s *Server) Stop(name string) {
s.mu.Lock()
defer s.mu.Unlock()
if cmd, ok := s.cmds[name]; ok {
if err := cmd.Process.Kill(); err != nil {
log.Printf("error killing %q: %v", name, err)
}
delete(s.cmds, name)
}
}
func (s *Server) RunInstance(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.cmds[name]; ok {
return fmt.Errorf("instance %q already running", name)
}
if !validInstanceName(name) {
return fmt.Errorf("invalid instance name %q", name)
}
dir := filepath.Join(mtsRoot(), name)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
env := os.Environ()
env = append(env, "TS_DEBUG_LOG_RATE=all")
if ef, err := os.Open(instEnvFile(name)); err == nil {
defer ef.Close()
sc := bufio.NewScanner(ef)
for sc.Scan() {
t := strings.TrimSpace(sc.Text())
if strings.HasPrefix(t, "#") || !strings.Contains(t, "=") {
continue
}
env = append(env, t)
}
} else if os.IsNotExist(err) {
// Write an example one.
os.WriteFile(instEnvFile(name), fmt.Appendf(nil, "# Example mts env.txt file; uncomment/add stuff you want for %q\n\n#TS_DEBUG_MAP=1\n#TS_DEBUG_REGISTER=1\n#TS_NO_LOGS_NO_SUPPORT=1\n", name), 0600)
}
extraArgs := []string{"--verbose=1"}
if af, err := os.Open(instArgsFile(name)); err == nil {
extraArgs = nil // clear default args
defer af.Close()
sc := bufio.NewScanner(af)
for sc.Scan() {
t := strings.TrimSpace(sc.Text())
if strings.HasPrefix(t, "#") || t == "" {
continue
}
extraArgs = append(extraArgs, t)
}
} else if os.IsNotExist(err) {
// Write an example one.
os.WriteFile(instArgsFile(name), fmt.Appendf(nil, "# Example mts args.txt file for instance %q.\n# One line per extra arg to tailscaled; no magic string quoting\n\n--verbose=1\n#--socks5-server=127.0.0.1:5000\n", name), 0600)
}
log.Printf("Running Tailscale daemon %q in %q", name, dir)
args := []string{
"--tun=userspace-networking",
"--statedir=" + filepath.Join(dir),
"--socket=" + filepath.Join(dir, "tailscaled.sock"),
}
args = append(args, extraArgs...)
cmd := exec.Command(s.tailscaled(), args...)
cmd.Dir = dir
cmd.Env = env
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = cmd.Stdout
logs := instLogsFile(name)
logFile, err := os.OpenFile(logs, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("opening logs file: %w", err)
}
go func() {
bs := bufio.NewScanner(out)
for bs.Scan() {
// TODO(bradfitz): record in memory too, serve via HTTP
line := strings.TrimSpace(bs.Text())
fmt.Fprintf(logFile, "%s\n", line)
fmt.Printf("tailscaled[%s]: %s\n", name, line)
}
}()
if err := cmd.Start(); err != nil {
return err
}
go func() {
err := cmd.Wait()
logFile.Close()
log.Printf("Tailscale daemon %q exited: %v", name, err)
s.mu.Lock()
defer s.mu.Unlock()
delete(s.cmds, name)
}()
mak.Set(&s.cmds, name, cmd)
return nil
}
type listResponse struct {
// Instances maps instance name to its details.
Instances map[string]listResponseInstance `json:"instances"`
}
type listResponseInstance struct {
Name string `json:"name"`
Dir string `json:"dir"`
Sock string `json:"sock"`
Running bool `json:"running"`
Env string `json:"env"`
Args string `json:"args"`
Logs string `json:"logs"`
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w)
e.SetIndent("", " ")
e.Encode(v)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/list" {
var res listResponse
for _, name := range s.InstanceNames() {
mak.Set(&res.Instances, name, listResponseInstance{
Name: name,
Dir: instDir(name),
Sock: instSock(name),
Running: s.InstanceRunning(name),
Env: instEnvFile(name),
Args: instArgsFile(name),
Logs: instLogsFile(name),
})
}
writeJSON(w, res)
return
}
if r.URL.Path == "/rm" || r.URL.Path == "/stop" {
shouldRemove := r.URL.Path == "/rm"
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
target := r.FormValue("name")
var ok bool
for _, name := range s.InstanceNames() {
if name != target {
continue
}
ok = true
s.Stop(name)
if shouldRemove {
if err := os.RemoveAll(instDir(name)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
break
}
writeJSON(w, ok)
return
}
if inst, ok := strings.CutPrefix(r.URL.Path, "/create/"); ok {
if !s.InstanceRunning(inst) {
if err := s.RunInstance(inst); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
fmt.Fprintf(w, "OK\n")
return
}
if r.URL.Path == "/" {
fmt.Fprintf(w, "This is mts, the multi-tailscaled server.\n")
return
}
http.NotFound(w, r)
}
func (s *Server) InstanceNames() []string {
var ret []string
des, err := os.ReadDir(mtsRoot())
if err != nil {
if os.IsNotExist(err) {
return nil
}
panic(err)
}
for _, de := range des {
if !de.IsDir() {
continue
}
ret = append(ret, de.Name())
}
return ret
}
func mtsRoot() string {
dir, err := os.UserConfigDir()
if err != nil {
panic(err)
}
return filepath.Join(dir, "multi-tailscale-dev")
}
func instDir(name string) string {
return filepath.Join(mtsRoot(), name)
}
func instSock(name string) string {
return filepath.Join(instDir(name), "tailscaled.sock")
}
func instEnvFile(name string) string {
return filepath.Join(mtsRoot(), name, "env.txt")
}
func instArgsFile(name string) string {
return filepath.Join(mtsRoot(), name, "args.txt")
}
func instLogsFile(name string) string {
return filepath.Join(mtsRoot(), name, "logs.txt")
}
func mtsSock() string {
return filepath.Join(mtsRoot(), "mts.sock")
}

View File

@@ -14,7 +14,7 @@ import (
"os"
"runtime"
"tailscale.com/tsweb/promvarz"
"tailscale.com/feature"
"tailscale.com/tsweb/varz"
"tailscale.com/version"
)
@@ -37,6 +37,11 @@ type DebugHandler struct {
title string // title displayed on index page
}
// PrometheusHandler is an optional hook to enable native Prometheus
// support in the debug handler. It is disabled by default. Import the
// tailscale.com/tsweb/promvarz package to enable this feature.
var PrometheusHandler feature.Hook[func(*DebugHandler)]
// Debugger returns the DebugHandler registered on mux at /debug/,
// creating it if necessary.
func Debugger(mux *http.ServeMux) *DebugHandler {
@@ -53,7 +58,11 @@ func Debugger(mux *http.ServeMux) *DebugHandler {
ret.KVFunc("Uptime", func() any { return varz.Uptime() })
ret.KV("Version", version.Long())
ret.Handle("vars", "Metrics (Go)", expvar.Handler())
ret.Handle("varz", "Metrics (Prometheus)", http.HandlerFunc(promvarz.Handler))
if PrometheusHandler.IsSet() {
PrometheusHandler.Get()(ret)
} else {
ret.Handle("varz", "Metrics (Prometheus)", http.HandlerFunc(varz.Handler))
}
// pprof.Index serves everything that runtime/pprof.Lookup finds:
// goroutine, threadcreate, heap, allocs, block, mutex

View File

@@ -11,12 +11,21 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/expfmt"
"tailscale.com/tsweb"
"tailscale.com/tsweb/varz"
)
// Handler returns Prometheus metrics exported by our expvar converter
func init() {
tsweb.PrometheusHandler.Set(registerVarz)
}
func registerVarz(debug *tsweb.DebugHandler) {
debug.Handle("varz", "Metrics (Prometheus)", http.HandlerFunc(handler))
}
// handler returns Prometheus metrics exported by our expvar converter
// and the official Prometheus client.
func Handler(w http.ResponseWriter, r *http.Request) {
func handler(w http.ResponseWriter, r *http.Request) {
if err := gatherNativePrometheusMetrics(w); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))

View File

@@ -23,7 +23,7 @@ func TestHandler(t *testing.T) {
testVar1.Set(42)
testVar2.Set(4242)
svr := httptest.NewServer(http.HandlerFunc(Handler))
svr := httptest.NewServer(http.HandlerFunc(handler))
defer svr.Close()
want := `

View File

@@ -14,6 +14,7 @@ import (
"fmt"
"io"
"log"
"runtime"
"strings"
"sync"
"time"
@@ -162,6 +163,10 @@ func RateLimitedFnWithClock(logf Logf, f time.Duration, burst int, maxCache int,
if envknob.String("TS_DEBUG_LOG_RATE") == "all" {
return logf
}
if runtime.GOOS == "plan9" {
// To ease bring-up.
return logf
}
var (
mu sync.Mutex
msgLim = make(map[string]*limitData) // keyed by logf format

View File

@@ -1,6 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios
package eventbus
import (

View File

@@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios
package eventbus
import "tailscale.com/tsweb"
func registerHTTPDebugger(d *Debugger, td *tsweb.DebugHandler) {
// The event bus debugging UI uses html/template, which uses
// reflection for method lookups. This forces the compiler to
// retain a lot more code and information to make dynamic method
// dispatch work, which is unacceptable bloat for the iOS build.
//
// TODO: https://github.com/tailscale/tailscale/issues/15297 to
// bring the debug UI back to iOS somehow.
}

View File

@@ -19,6 +19,10 @@ import (
// an error. It will first try to use the 'id' command to get the group IDs,
// and if that fails, it will fall back to the user.GroupIds method.
func GetGroupIds(user *user.User) ([]string, error) {
if runtime.GOOS == "plan9" {
return nil, nil
}
if runtime.GOOS != "linux" {
return user.GroupIds()
}

View File

@@ -54,9 +54,18 @@ func lookup(usernameOrUID string, std lookupStd, wantShell bool) (*user.User, st
// Skip getent entirely on Non-Unix platforms that won't ever have it.
// (Using HasPrefix for "wasip1", anticipating that WASI support will
// move beyond "preview 1" some day.)
if runtime.GOOS == "windows" || runtime.GOOS == "js" || runtime.GOARCH == "wasm" {
if runtime.GOOS == "windows" || runtime.GOOS == "js" || runtime.GOARCH == "wasm" || runtime.GOOS == "plan9" {
var shell string
if wantShell && runtime.GOOS == "plan9" {
shell = "/bin/rc"
}
if runtime.GOOS == "plan9" {
if u, err := user.Current(); err == nil {
return u, shell, nil
}
}
u, err := std(usernameOrUID)
return u, "", err
return u, shell, err
}
// No getent on Gokrazy. So hard-code the login shell.
@@ -78,6 +87,16 @@ func lookup(usernameOrUID string, std lookupStd, wantShell bool) (*user.User, st
return u, shell, nil
}
if runtime.GOOS == "plan9" {
return &user.User{
Uid: "0",
Gid: "0",
Username: "glenda",
Name: "Glenda",
HomeDir: "/",
}, "/bin/rc", nil
}
// Start with getent if caller wants to get the user shell.
if wantShell {
return userLookupGetent(usernameOrUID, std)

View File

@@ -3018,6 +3018,10 @@ func (c *Conn) DebugForcePreferDERP(n int) {
// portableTrySetSocketBuffer sets SO_SNDBUF and SO_RECVBUF on pconn to socketBufferSize,
// logging an error if it occurs.
func portableTrySetSocketBuffer(pconn nettype.PacketConn, logf logger.Logf) {
if runtime.GOOS == "plan9" {
// Not supported. Don't try. Avoid logspam.
return
}
if c, ok := pconn.(*net.UDPConn); ok {
// Attempt to increase the buffer size, and allow failures.
if err := c.SetReadBuffer(socketBufferSize); err != nil {

View File

@@ -7,9 +7,11 @@ import (
"errors"
"net"
"net/netip"
"runtime"
"sync"
"sync/atomic"
"syscall"
"time"
"golang.org/x/net/ipv6"
"tailscale.com/net/netaddr"
@@ -150,6 +152,12 @@ func (c *RebindingUDPConn) closeLocked() error {
return errNilPConn
}
c.port = 0
if runtime.GOOS == "plan9" {
// Work around Go bug https://github.com/golang/go/issues/72770.
// This does https://go-review.googlesource.com/c/go/+/656395
// manually until the upstream Go bug is fixed + released.
c.pconn.SetReadDeadline(time.Now().Add(-time.Hour))
}
return c.pconn.Close()
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows && !linux && !darwin && !openbsd && !freebsd
//go:build !windows && !linux && !darwin && !openbsd && !freebsd && !plan9
package router

View File

@@ -0,0 +1,158 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package router
import (
"bufio"
"bytes"
"fmt"
"net/netip"
"os"
"strings"
"github.com/tailscale/wireguard-go/tun"
"tailscale.com/health"
"tailscale.com/net/netmon"
"tailscale.com/types/logger"
)
func newUserspaceRouter(logf logger.Logf, tundev tun.Device, netMon *netmon.Monitor, health *health.Tracker) (Router, error) {
r := &plan9Router{
logf: logf,
tundev: tundev,
netMon: netMon,
}
cleanAllTailscaleRoutes(logf)
return r, nil
}
type plan9Router struct {
logf logger.Logf
tundev tun.Device
netMon *netmon.Monitor
health *health.Tracker
}
func (r *plan9Router) Up() error {
return nil
}
func (r *plan9Router) Set(cfg *Config) error {
if cfg == nil {
cleanAllTailscaleRoutes(r.logf)
return nil
}
var self4, self6 netip.Addr
for _, addr := range cfg.LocalAddrs {
ctl := r.tundev.File()
maskBits := addr.Bits()
if addr.Addr().Is4() {
// The mask sizes in Plan9 are in IPv6 bits, even for IPv4.
maskBits += (128 - 32)
self4 = addr.Addr()
}
if addr.Addr().Is6() {
self6 = addr.Addr()
}
_, err := fmt.Fprintf(ctl, "add %s /%d\n", addr.Addr().String(), maskBits)
r.logf("route/plan9: add %s /%d = %v", addr.Addr().String(), maskBits, err)
}
ipr, err := os.OpenFile("/net/iproute", os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("open /net/iproute: %w", err)
}
defer ipr.Close()
// TODO(bradfitz): read existing routes, delete ones tagged "tail"
// that aren't in cfg.LocalRoutes.
if _, err := fmt.Fprintf(ipr, "tag tail\n"); err != nil {
return fmt.Errorf("tag tail: %w", err)
}
for _, route := range cfg.Routes {
maskBits := route.Bits()
if route.Addr().Is4() {
// The mask sizes in Plan9 are in IPv6 bits, even for IPv4.
maskBits += (128 - 32)
}
var nextHop netip.Addr
if route.Addr().Is4() {
nextHop = self4
} else if route.Addr().Is6() {
nextHop = self6
}
if !nextHop.IsValid() {
r.logf("route/plan9: skipping route %s: no next hop (no self addr)", route.String())
continue
}
r.logf("route/plan9: plan9.router: add %s /%d %s", route.Addr(), maskBits, nextHop)
if _, err := fmt.Fprintf(ipr, "add %s /%d %s\n", route.Addr(), maskBits, nextHop); err != nil {
return fmt.Errorf("add %s: %w", route.String(), err)
}
}
if len(cfg.LocalRoutes) > 0 {
r.logf("route/plan9: TODO: Set LocalRoutes %v", cfg.LocalRoutes)
}
if len(cfg.SubnetRoutes) > 0 {
r.logf("route/plan9: TODO: Set SubnetRoutes %v", cfg.SubnetRoutes)
}
return nil
}
// UpdateMagicsockPort implements the Router interface. This implementation
// does nothing and returns nil because this router does not currently need
// to know what the magicsock UDP port is.
func (r *plan9Router) UpdateMagicsockPort(_ uint16, _ string) error {
return nil
}
func (r *plan9Router) Close() error {
// TODO(bradfitz): unbind
return nil
}
func cleanUp(logf logger.Logf, _ string) {
cleanAllTailscaleRoutes(logf)
}
func cleanAllTailscaleRoutes(logf logger.Logf) {
routes, err := os.OpenFile("/net/iproute", os.O_RDWR, 0)
if err != nil {
logf("cleaning routes: %v", err)
return
}
defer routes.Close()
// Using io.ReadAll or os.ReadFile on /net/iproute fails; it results in a
// 511 byte result when the actual /net/iproute contents are over 1k.
// So do it in one big read instead. Who knows.
routeBuf := make([]byte, 1<<20)
n, err := routes.Read(routeBuf)
if err != nil {
logf("cleaning routes: %v", err)
return
}
routeBuf = routeBuf[:n]
//logf("cleaning routes: %d bytes: %q", len(routeBuf), routeBuf)
bs := bufio.NewScanner(bytes.NewReader(routeBuf))
for bs.Scan() {
f := strings.Fields(bs.Text())
if len(f) < 6 {
continue
}
tag := f[4]
if tag != "tail" {
continue
}
_, err := fmt.Fprintf(routes, "remove %s %s\n", f[0], f[1])
logf("router: cleaning route %s %s: %v", f[0], f[1], err)
}
}

View File

@@ -569,6 +569,18 @@ func (e *userspaceEngine) handleLocalPackets(p *packet.Parsed, t *tstun.Wrapper)
return filter.Drop
}
}
if runtime.GOOS == "plan9" {
isLocalAddr, ok := e.isLocalAddr.LoadOk()
if ok {
if isLocalAddr(p.Dst.Addr()) {
e.logf("XXX plan9 inject inbound")
// On Plan9's "tun" equivalent, everything goes back in and out
// the tun, even when the kernel's replying to itself.
t.InjectInboundCopy(p.Buffer())
return filter.Drop
}
}
}
return filter.Accept
}