Compare commits

...

8 Commits

Author SHA1 Message Date
Joe Tsai
2e20bd2ffe util/httpio: prototype design for handling I/O in HTTP
Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2024-01-11 15:30:43 -08:00
Will Norris
b89c113365 client/web: skip connectivity check on https
The manage client always listens on http (non-secure) port 5252.  If the
login client is loaded over https, then the connectivity check to `/ok`
will fail with a mixed-content error. Mixed-content enforcement is a
browser setting that we have no control over, so there's no way around
this.

In this case of the login client being loaded over https, we skip the
connectivity check entirely.  We will always render the sign-in button,
though we don't know for sure if the user has connectivity, so we
provide some additional help text in case they have trouble signing in.

Updates hassio-addons/addon-tailscale#314

Signed-off-by: Will Norris <will@tailscale.com>
2024-01-11 14:51:29 -08:00
James Tucker
ff9c1ebb4a derp: reduce excess goroutines blocking on broadcasts
Observed on one busy derp node, there were 600 goroutines blocked
writing to this channel, which represents not only more blocked routines
than we need, but also excess wake-ups downstream as the latent
goroutines writes represent no new work.

Updates #self

Signed-off-by: James Tucker <james@tailscale.com>
2024-01-11 14:47:17 -08:00
Irbe Krumina
5cc1bfe82d cmd/k8s-operator: remove configuration knob for Connector (#10791)
The configuration knob (that defaulted to Connector being disabled)
was added largely because the Connector CRD had to be installed in a separate step.
Now when the CRD has been added to both chart and static manifest, we can have it on by default.

Updates tailscale/tailscale#10878

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-01-11 20:03:53 +00:00
Irbe Krumina
469af614b0 cmd/k8s-operator: fix base truncating for extra long Service names (#10825)
cmd/k8s-operator: fix base truncating for extra long Service names

StatefulSet names for ingress/egress proxies are calculated
using Kubernetes name generator and the parent resource name
as a base.
The name generator also cuts the base, but has a higher max cap.
This commit fixes a bug where, if we get a shortened base back
from the generator, we cut off too little as the base that we
have cut will be passed into the generator again, which will
then itself cut less because the base is shorter- so we end up
with a too long name again.

Updates tailscale/tailscale#10807

Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Irbe Krumina <irbekrm@gmail.com>
2024-01-11 20:02:03 +00:00
Sonia Appasamy
331a6d105f client/web: add initial types for using peer capabilities
Sets up peer capability types for future use within the web client
views and APIs.

Updates tailscale/corp#16695

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2024-01-11 11:20:24 -05:00
Andrew Dunham
6540d1f018 wgengine/router: look up absolute path to netsh.exe on Windows
This is in response to logs from a customer that show that we're unable
to run netsh due to the following error:

    router: firewall: adding Tailscale-Process rule to allow UDP for "C:\\Program Files\\Tailscale\\tailscaled.exe" ...
    router: firewall: error adding Tailscale-Process rule: exec: "netsh": cannot run executable found relative to current directory:

There's approximately no reason to ever dynamically look up the path of
a system utility like netsh.exe, so instead let's first look for it
in the System32 directory and only if that fails fall back to the
previous behaviour.

Updates #10804

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I68cfeb4cab091c79ccff3187d35f50359a690573
2024-01-10 20:20:19 -05:00
Irbe Krumina
ca48db0d60 Makefile,build_docker.sh: allow to configure target platform. (#10806)
Build dev tailscale and k8s-operator images for linux/amd64 only by default,
make it possible to configure target build platform via PLATFORM var.

Updates#cleanup

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-01-10 19:19:20 +00:00
23 changed files with 769 additions and 76 deletions

View File

@@ -3,6 +3,8 @@ SYNO_ARCH ?= "amd64"
SYNO_DSM ?= "7"
TAGS ?= "latest"
PLATFORM ?= "flyio" ## flyio==linux/amd64. Set to "" to build all platforms.
vet: ## Run go vet
./tool/go vet ./...
@@ -88,7 +90,7 @@ publishdevimage: ## Build and publish tailscale image to location specified by $
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=client ./build_docker.sh
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=client ./build_docker.sh
publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@@ -96,7 +98,7 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"

View File

@@ -32,6 +32,7 @@ PUSH="${PUSH:-false}"
TARGET="${TARGET:-${DEFAULT_TARGET}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
PLATFORM="${PLATFORM:-}" # default to all platforms
case "$TARGET" in
client)
@@ -50,6 +51,7 @@ case "$TARGET" in
--tags="${TAGS}" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
/usr/local/bin/containerboot
;;
operator)
@@ -65,6 +67,7 @@ case "$TARGET" in
--tags="${TAGS}" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
/usr/local/bin/operator
;;
*)

View File

@@ -8,6 +8,7 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
@@ -232,3 +233,55 @@ func (s *Server) newSessionID() (string, error) {
}
return "", errors.New("too many collisions generating new session; please refresh page")
}
type peerCapabilities map[capFeature]bool // value is true if the peer can edit the given feature
// canEdit is true if the peerCapabilities grant edit access
// to the given feature.
func (p peerCapabilities) canEdit(feature capFeature) bool {
if p == nil {
return false
}
if p[capFeatureAll] {
return true
}
return p[feature]
}
type capFeature string
const (
// The following values should not be edited.
// New caps can be added, but existing ones should not be changed,
// as these exact values are used by users in tailnet policy files.
capFeatureAll capFeature = "*" // grants peer management of all features
capFeatureFunnel capFeature = "funnel" // grants peer serve/funnel management
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
capFeatureSubnet capFeature = "subnet" // grants peer subnet routes management
capFeatureExitNode capFeature = "exitnode" // grants peer ability to advertise-as and use exit nodes
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
)
type capRule struct {
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
}
// toPeerCapabilities parses out the web ui capabilities from the
// given whois response.
func toPeerCapabilities(whois *apitype.WhoIsResponse) (peerCapabilities, error) {
caps := peerCapabilities{}
if whois == nil {
return caps, nil
}
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
}
for _, c := range rules {
for _, f := range c.CanEdit {
caps[capFeature(strings.ToLower(f))] = true
}
}
return caps, nil
}

View File

@@ -95,9 +95,16 @@ function LoginPopoverContent({
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
// Whether the current page is loaded over HTTPS.
// If it is, then the connectivity check to the management client
// will fail with a mixed-content error.
const isHTTPS = window.location.protocol === "https:"
const checkTSConnection = useCallback(() => {
if (auth.viewerIdentity) {
setCanConnectOverTS(true) // already connected over ts
if (auth.viewerIdentity || isHTTPS) {
// Skip the connectivity check if we either already know we're connected over Tailscale,
// or know the connectivity check will fail because the current page is loaded over HTTPS.
setCanConnectOverTS(true)
return
}
// Otherwise, test connection to the ts IP.
@@ -111,7 +118,7 @@ function LoginPopoverContent({
setIsRunningCheck(false)
})
.catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IPv4])
}, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
/**
* Checking connection for first time on page load.
@@ -193,6 +200,14 @@ function LoginPopoverContent({
You can see most of this device's details. To make changes,
you need to sign in.
</p>
{isHTTPS && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your
policy file allows access.
</p>
)}
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
)}

View File

@@ -450,10 +450,11 @@ type authResponse struct {
// viewerIdentity is the Tailscale identity of the source node
// connected to this web client.
type viewerIdentity struct {
LoginName string `json:"loginName"`
NodeName string `json:"nodeName"`
NodeIP string `json:"nodeIP"`
ProfilePicURL string `json:"profilePicUrl,omitempty"`
LoginName string `json:"loginName"`
NodeName string `json:"nodeName"`
NodeIP string `json:"nodeIP"`
ProfilePicURL string `json:"profilePicUrl,omitempty"`
Capabilities peerCapabilities `json:"capabilities"` // features peer is allowed to edit
}
// serverAPIAuth handles requests to the /api/auth endpoint
@@ -464,10 +465,16 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
session, whois, status, sErr := s.getSession(r)
if whois != nil {
caps, err := toPeerCapabilities(whois)
if err != nil {
http.Error(w, sErr.Error(), http.StatusInternalServerError)
return
}
resp.ViewerIdentity = &viewerIdentity{
LoginName: whois.UserProfile.LoginName,
NodeName: whois.Node.Name,
ProfilePicURL: whois.UserProfile.ProfilePicURL,
Capabilities: caps,
}
if addrs := whois.Node.Addresses; len(addrs) > 0 {
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()

View File

@@ -450,6 +450,7 @@ func TestServeAuth(t *testing.T) {
NodeName: remoteNode.Node.Name,
NodeIP: remoteIP,
ProfilePicURL: user.ProfilePicURL,
Capabilities: peerCapabilities{},
}
testControlURL := &defaultControlURL
@@ -1097,6 +1098,163 @@ func TestRequireTailscaleIP(t *testing.T) {
}
}
func TestPeerCapabilities(t *testing.T) {
// Testing web.toPeerCapabilities
toPeerCapsTests := []struct {
name string
whois *apitype.WhoIsResponse
wantCaps peerCapabilities
}{
{
name: "empty-whois",
whois: nil,
wantCaps: peerCapabilities{},
},
{
name: "no-webui-caps",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
},
},
wantCaps: peerCapabilities{},
},
{
name: "one-webui-cap",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
},
},
{
name: "multiple-webui-cap",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
"{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
capFeatureExitNode: true,
capFeatureAll: true,
},
},
{
name: "case=insensitive-caps",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"SSH\",\"sUBnet\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnet: true,
},
},
{
name: "random-canEdit-contents-dont-error",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"unknown-feature\"]}",
},
},
},
wantCaps: peerCapabilities{
"unknown-feature": true,
},
},
{
name: "no-canEdit-section",
whois: &apitype.WhoIsResponse{
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canDoSomething\":[\"*\"]}",
},
},
},
wantCaps: peerCapabilities{},
},
}
for _, tt := range toPeerCapsTests {
t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
got, err := toPeerCapabilities(tt.whois)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
if diff := cmp.Diff(got, tt.wantCaps); diff != "" {
t.Errorf("wrong caps; (-got+want):%v", diff)
}
})
}
// Testing web.peerCapabilities.canEdit
canEditTests := []struct {
name string
caps peerCapabilities
wantCanEdit map[capFeature]bool
}{
{
name: "empty-caps",
caps: nil,
wantCanEdit: map[capFeature]bool{
capFeatureAll: false,
capFeatureFunnel: false,
capFeatureSSH: false,
capFeatureSubnet: false,
capFeatureExitNode: false,
capFeatureAccount: false,
},
},
{
name: "some-caps",
caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{
capFeatureAll: false,
capFeatureFunnel: false,
capFeatureSSH: true,
capFeatureSubnet: false,
capFeatureExitNode: false,
capFeatureAccount: true,
},
},
{
name: "wildcard-in-caps",
caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{
capFeatureAll: true,
capFeatureFunnel: true,
capFeatureSSH: true,
capFeatureSubnet: true,
capFeatureExitNode: true,
capFeatureAccount: true,
},
},
}
for _, tt := range canEditTests {
t.Run("canEdit-"+tt.name, func(t *testing.T) {
for f, want := range tt.wantCanEdit {
if got := tt.caps.canEdit(f); got != want {
t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want)
}
}
})
}
}
var (
defaultControlURL = "https://controlplane.tailscale.com"
testAuthPath = "/a/12345"

View File

@@ -59,8 +59,6 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: ENABLE_CONNECTOR
value: "{{ .Values.enableConnector }}"
- name: CLIENT_ID_FILE
value: /oauth/client_id
- name: CLIENT_SECRET_FILE

View File

@@ -8,10 +8,6 @@ oauth: {}
# clientId: ""
# clientSecret: ""
# enableConnector determines whether the operator should reconcile
# connector.tailscale.com custom resources.
enableConnector: "false"
# installCRDs determines whether tailscale.com CRDs should be installed as part
# of chart installation. We do not use Helm's CRD installation mechanism as that
# does not allow for upgrading CRDs.

View File

@@ -286,8 +286,6 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: ENABLE_CONNECTOR
value: "false"
- name: CLIENT_ID_FILE
value: /oauth/client_id
- name: CLIENT_SECRET_FILE

View File

@@ -62,7 +62,6 @@ func main() {
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
tsEnableConnector = defaultBool("ENABLE_CONNECTOR", false)
)
var opts []kzap.Opts
@@ -93,7 +92,7 @@ func main() {
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
// TODO (irbekrm): gather the reconciler options into an opts struct
// rather than passing a million of them in one by one.
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode, tsEnableConnector)
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
}
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
@@ -201,7 +200,7 @@ waitOnline:
// runReconcilers starts the controller-runtime manager and registers the
// ServiceReconciler. It blocks forever.
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string, enableConnector bool) {
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
var (
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
)
@@ -222,9 +221,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
&appsv1.StatefulSet{}: nsFilter,
},
},
}
if enableConnector {
mgrOpts.Scheme = tsapi.GlobalScheme
Scheme: tsapi.GlobalScheme,
}
mgr, err := manager.New(restConfig, mgrOpts)
if err != nil {
@@ -278,22 +275,20 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
startlog.Fatalf("could not create controller: %v", err)
}
if enableConnector {
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("subnetrouter"))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.Connector{}).
Watches(&appsv1.StatefulSet{}, connectorFilter).
Watches(&corev1.Secret{}, connectorFilter).
Complete(&ConnectorReconciler{
ssr: ssr,
recorder: eventRecorder,
Client: mgr.GetClient(),
logger: zlog.Named("connector-reconciler"),
clock: tstime.DefaultClock{},
})
if err != nil {
startlog.Fatal("could not create connector reconciler: %v", err)
}
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("connector"))
err = builder.ControllerManagedBy(mgr).
For(&tsapi.Connector{}).
Watches(&appsv1.StatefulSet{}, connectorFilter).
Watches(&corev1.Secret{}, connectorFilter).
Complete(&ConnectorReconciler{
ssr: ssr,
recorder: eventRecorder,
Client: mgr.GetClient(),
logger: zlog.Named("connector-reconciler"),
clock: tstime.DefaultClock{},
})
if err != nil {
startlog.Fatal("could not create connector reconciler: %v", err)
}
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {

View File

@@ -214,18 +214,19 @@ const maxStatefulSetNameLength = 63 - 10 - 1
// generation will NOT result in a StatefulSet name longer than 52 chars.
// This is done because of https://github.com/kubernetes/kubernetes/issues/64023.
func statefulSetNameBase(parent string) string {
base := fmt.Sprintf("ts-%s-", parent)
// Calculate what length name GenerateName returns for this base.
generator := names.SimpleNameGenerator
generatedName := generator.GenerateName(base)
if excess := len(generatedName) - maxStatefulSetNameLength; excess > 0 {
base = base[:len(base)-excess-1] // take extra char off to make space for hyphen
base = base + "-" // re-instate hyphen
for {
generatedName := generator.GenerateName(base)
excess := len(generatedName) - maxStatefulSetNameLength
if excess <= 0 {
return base
}
base = base[:len(base)-1-excess] // cut off the excess chars
if !strings.HasSuffix(base, "-") { // dash may have been cut by the generator
base = base + "-"
}
}
return base
}
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {

View File

@@ -6,6 +6,9 @@
package main
import (
"fmt"
"regexp"
"strings"
"testing"
)
@@ -19,32 +22,20 @@ import (
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/staging/src/k8s.io/apiserver/pkg/storage/names/generate.go#L45.
// https://github.com/kubernetes/kubernetes/pull/116430
func Test_statefulSetNameBase(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{
name: "43 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "44 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xbo",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "42 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x-",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := statefulSetNameBase(tt.in); got != tt.out {
t.Errorf("stsNamePrefix(%s) = %q, want %s", tt.in, got, tt.out)
}
})
// Service name lengths can be 1 - 63 chars, be paranoid and test them all.
var b strings.Builder
for b.Len() < 63 {
if _, err := b.WriteString("a"); err != nil {
t.Fatalf("error writing to string builder: %v", err)
}
baseLength := len(b.String())
if baseLength > 43 {
baseLength = 43 // currently 43 is the max base length
}
wantsNameR := regexp.MustCompile(`^ts-a{` + fmt.Sprint(baseLength) + `}-$`) // to match a string like ts-aaaa-
gotName := statefulSetNameBase(b.String())
if !wantsNameR.MatchString(gotName) {
t.Fatalf("expected string %s to match regex %s ", gotName, wantsNameR.String()) // fatal rather than error as this test is called 63 times
}
}
}

View File

@@ -1573,6 +1573,17 @@ func (c *sclient) sendMeshUpdates() error {
c.s.mu.Lock()
defer c.s.mu.Unlock()
// allow all happened-before mesh update request goroutines to complete, if
// we don't finish the task we'll queue another below.
drainUpdates:
for {
select {
case <-c.meshUpdate:
default:
break drainUpdates
}
}
writes := 0
for _, pcs := range c.peerStateChange {
if c.bw.Available() <= frameHeaderLen+keyLen {

View File

@@ -1341,6 +1341,9 @@ const (
PeerCapabilityWakeOnLAN PeerCapability = "https://tailscale.com/cap/wake-on-lan"
// PeerCapabilityIngress grants the ability for a peer to send ingress traffic.
PeerCapabilityIngress PeerCapability = "https://tailscale.com/cap/ingress"
// PeerCapabilityWebUI grants the ability for a peer to edit features from the
// device Web UI.
PeerCapabilityWebUI PeerCapability = "tailscale.com/cap/webui"
)
// NodeCapMap is a map of capabilities to their optional values. It is valid for

81
util/httphdr/auth.go Normal file
View File

@@ -0,0 +1,81 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package httphdr
import (
"bytes"
"encoding/base64"
"fmt"
"strings"
)
// TODO: Must authorization parameters be valid UTF-8?
// AuthScheme is an authorization scheme per RFC 7235.
// Per section 2.1, the "Authorization" header is formatted as:
//
// Authorization: <auth-scheme> <auth-parameter>
//
// A scheme implementation must self-report the <auth-scheme> name and
// provide the ability to marshal and unmarshal the <auth-parameter>.
//
// For concrete implementations, see [Basic] and [Bearer].
type AuthScheme interface {
// AuthScheme is the authorization scheme name.
// It must be valid according to RFC 7230, section 3.2.6.
AuthScheme() string
// MarshalAuth marshals the authorization parameter for the scheme.
MarshalAuth() (string, error)
// UnmarshalAuth unmarshals the authorization parameter for the scheme.
UnmarshalAuth(string) error
}
// BasicAuth is the Basic authorization scheme as defined in RFC 2617.
type BasicAuth struct {
Username string // must not contain ':' per section 2
Password string
}
func (BasicAuth) AuthScheme() string { return "Basic" }
func (a BasicAuth) MarshalAuth() (string, error) {
if strings.IndexByte(a.Username, ':') >= 0 {
return "", fmt.Errorf("invalid username: contains a colon")
}
return base64.StdEncoding.EncodeToString([]byte(a.Username + ":" + a.Password)), nil
}
func (a *BasicAuth) UnmarshalAuth(s string) error {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return fmt.Errorf("invalid basic authorization: %w", err)
}
i := bytes.IndexByte(b, ':')
if i < 0 {
return fmt.Errorf("invalid basic authorization: missing a colon")
}
a.Username = string(b[:i])
a.Password = string(b[i+len(":"):])
return nil
}
// BearerAuth is the Bearer Token authorization scheme as defined in RFC 6750.
type BearerAuth struct {
Token string // usually a base64-encoded string per section 2.1
}
func (BearerAuth) AuthScheme() string { return "Bearer" }
func (a BearerAuth) MarshalAuth() (string, error) {
// TODO: Verify that token is valid base64?
return a.Token, nil
}
func (a *BearerAuth) UnmarshalAuth(s string) error {
// TODO: Verify that token is valid base64?
a.Token = s
return nil
}

43
util/httpio/context.go Normal file
View File

@@ -0,0 +1,43 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package httpio
import (
"context"
"net/http"
"tailscale.com/util/httphdr"
)
type headerKey struct{}
// WithHeader specifies the HTTP header to use with a client request.
// It only affects [Do], [Get], [Post], [Put], and [Delete].
//
// Example usage:
//
// ctx = httpio.WithHeader(ctx, http.Header{"DD-API-KEY": ...})
func WithHeader(ctx context.Context, hdr http.Header) context.Context {
return context.WithValue(ctx, headerKey{}, hdr)
}
type authKey struct{}
// WithAuth specifies an "Authorization" header to use with a client request.
// This takes precedence over any "Authorization" header that may be present
// in the [http.Header] provided to [WithHeader].
// It only affects [Do], [Get], [Post], [Put], and [Delete].
//
// Example usage:
//
// ctx = httpio.WithAuth(ctx, httphdr.BasicAuth{
// Username: "admin",
// Password: "password",
// })
func WithAuth(ctx context.Context, auth httphdr.AuthScheme) context.Context {
return context.WithValue(ctx, authKey{}, auth)
}
// TODO: Add extraction functionality to retrieve the original
// *http.Request and http.ResponseWriter for use with [Handler].

93
util/httpio/endpoint.go Normal file
View File

@@ -0,0 +1,93 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package httpio
import (
"context"
"strings"
)
// Endpoint annotates an HTTP method and path with input and output types.
//
// The intent is to declare this in a shared package between client and server
// implementations as a means to structurally describe how they interact.
//
// Example usage:
//
// package tsapi
//
// const BaseURL = "https://api.tailscale.com/api/v2/"
//
// var (
// GetDevice = httpio.Endpoint[GetDeviceRequest, GetDeviceResponse]{Method: "GET", Pattern: "/device/{DeviceID}"}.WithHost(BaseURL)
// DeleteDevice = httpio.Endpoint[DeleteDeviceRequest, DeleteDeviceResponse]{Method: "DELETE", Pattern: "/device/{DeviceID}"}.WithHost(BaseURL)
// )
//
// type GetDeviceRequest struct {
// ID int `urlpath:"DeviceID"`
// Fields []string `urlquery:"fields"`
// ...
// }
// type GetDeviceResponse struct {
// ID int `json:"id"`
// Addresses []netip.Addr `json:"addresses"`
// ...
// }
// type DeleteDeviceRequest struct { ... }
// type DeleteDeviceResponse struct { ... }
//
// Example usage by client code:
//
// ctx = httpio.WithAuth(ctx, ...)
// device, err := tsapi.GetDevice.Do(ctx, {ID: 1234})
//
// Example usage by server code:
//
// mux := http.NewServeMux()
// mux.Handle(tsapi.GetDevice.String(), checkAuth(httpio.Handler(getDevice)))
// mux.Handle(tsapi.DeleteDevice.String(), checkAuth(httpio.Handler(deleteDevice)))
//
// func checkAuth(http.Handler) http.Handler { ... }
// func getDevice(ctx context.Context, in GetDeviceRequest) (out GetDeviceResponse, err error) { ... }
// func deleteDevice(ctx context.Context, in DeleteDeviceRequest) (out DeleteDeviceResponse, err error) { ... }
type Endpoint[In Request, Out Response] struct {
// Method is a valid HTTP method (e.g., "GET").
Method string
// Pattern must be a pattern that complies with [mux.ServeMux.Handle] and
// not be preceded by a method or host (e.g., "/api/v2/device/{DeviceID}").
// It must start with a leading "/".
Pattern string
}
// String returns a combination of the method and pattern,
// which is a valid pattern for [mux.ServeMux.Handle].
func (e Endpoint[In, Out]) String() string { return e.Method + " " + e.Pattern }
// Do performs an HTTP call to the target endpoint at the specified host.
// The hostPrefix must be a URL prefix containing the scheme and host,
// but not contain any URL query parameters (e.g., "https://api.tailscale.com/api/v2/").
func (e Endpoint[In, Out]) Do(ctx context.Context, hostPrefix string, in In, opts ...Option) (out Out, err error) {
return Do[In, Out](ctx, e.Method, strings.TrimRight(hostPrefix, "/")+e.Pattern, in, opts...)
}
// TODO: Should hostPrefix be a *url.URL?
// WithHost constructs a [HostedEndpoint],
// which is an HTTP endpoint hosted at a particular URL prefix.
func (e Endpoint[In, Out]) WithHost(hostPrefix string) HostedEndpoint[In, Out] {
return HostedEndpoint[In, Out]{Prefix: hostPrefix, Endpoint: e}
}
// HostedEndpoint is an HTTP endpoint hosted under a particular URL prefix.
type HostedEndpoint[In Request, Out Response] struct {
// Prefix is a URL prefix containing the scheme, host, and
// an optional path prefix (e.g., "https://api.tailscale.com/api/v2/").
Prefix string
Endpoint[In, Out]
}
// Do performs an HTTP call to the target hosted endpoint.
func (e HostedEndpoint[In, Out]) Do(ctx context.Context, in In, opts ...Option) (out Out, err error) {
return Do[In, Out](ctx, e.Method, strings.TrimSuffix(e.Prefix, "/")+e.Pattern, in, opts...)
}

121
util/httpio/httpio.go Normal file
View File

@@ -0,0 +1,121 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package httpio assists in handling HTTP operations on structured
// input and output types. It automatically handles encoding of data
// in the URL path, URL query parameters, and the HTTP body.
package httpio
import (
"context"
"net/http"
"tailscale.com/util/httpm"
)
// Request is a structured Go type that contains fields representing arguments
// in the URL path, URL query parameters, and optionally the HTTP request body.
//
// Typically, this is a Go struct:
//
// - with fields tagged as `urlpath` to represent arguments in the URL path
// (e.g., "/tailnet/{tailnetId}/devices/{deviceId}").
// See [tailscale.com/util/httpio/urlpath] for details.
//
// - with fields tagged as `urlquery` to represent URL query parameters
// (e.g., "?after=18635&limit=5").
// See [tailscale.com/util/httpio/urlquery] for details.
//
// - with possibly other fields used to serialize as the HTTP body.
// By default, [encoding/json] is used to marshal the entire struct value.
// To prevent fields specific to `urlpath` or `urlquery` from being marshaled
// as part of the body, explicitly ignore those fields with `json:"-"`.
// An HTTP body is only populated if there are any exported fields
// without the `urlpath` or `urlquery` struct tags.
//
// Since GET and DELETE methods usually have no associated body,
// requests for such methods often only have `urlpath` and `urlquery` fields.
//
// Example GET request type:
//
// type GetDevicesRequest struct {
// TailnetID tailcfg.TailnetID `urlpath:"tailnetId"`
//
// Limit uint `urlquery:"limit"`
// After tailcfg.DeviceID `urlquery:"after"`
// }
//
// Example PUT request type:
//
// type PutDeviceRequest struct {
// TailnetID tailcfg.TailnetID `urlpath:"tailnetId" json:"-"`
// DeviceID tailcfg.DeviceID `urlpath:"deviceId" json:"-"`
//
// Hostname string `json:"hostname,omitempty"``
// IPv4 netip.IPAddr `json:"ipv4,omitzero"``
// }
//
// By convention, request struct types are named "{Method}{Resource}Request",
// where {Method} is the HTTP method (e.g., "Post, "Get", "Put", "Delete", etc.)
// and {Resource} is some resource acted upon (e.g., "Device", "Routes", etc.).
type Request = any
// Response is a structured Go type to represent the HTTP response body.
//
// By default, [encoding/json] is used to unmarshal the response value.
// Unlike [Request], there is no support for `urlpath` and `urlquery` struct tags.
//
// Example response type:
//
// type GetDevicesResponses struct {
// Devices []Device `json:"devices"`
// Error ErrorResponse `json:"error"`
// }
//
// By convention, response struct types are named "{Method}{Resource}Response",
// where {Method} is the HTTP method (e.g., "Post, "Get", "Put", "Delete", etc.)
// and {Resource} is some resource acted upon (e.g., "Device", "Routes", etc.).
type Response = any
// Handler wraps a caller-provided handle function that operates on
// concrete input and output types and returns a [http.Handler] function.
func Handler[In Request, Out Response](handle func(ctx context.Context, in In) (out Out, err error), opts ...Option) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: How do we respond to the user if err is non-nil?
// Do we default to status 500?
panic("not implemented")
})
}
// TODO: Should url be a *url.URL? In the usage below, the caller should not pass query parameters.
// Post performs a POST call to the provided url with the given input
// and returns the response output.
func Post[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
return Do[In, Out](ctx, httpm.POST, url, in, opts...)
}
// Get performs a GET call to the provided url with the given input
// and returns the response output.
func Get[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
return Do[In, Out](ctx, httpm.GET, url, in, opts...)
}
// Put performs a PUT call to the provided url with the given input
// and returns the response output.
func Put[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
return Do[In, Out](ctx, httpm.PUT, url, in, opts...)
}
// Delete performs a DELETE call to the provided url with the given input
// and returns the response output.
func Delete[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
return Do[In, Out](ctx, httpm.DELETE, url, in, opts...)
}
// Do performs an HTTP method call to the provided url with the given input
// and returns the response output.
func Do[In Request, Out Response](ctx context.Context, method, url string, in In, opts ...Option) (out Out, err error) {
// TOOD: If the server returned a non-2xx code, we should report a Go error.
panic("not implemented")
}

44
util/httpio/options.go Normal file
View File

@@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package httpio
import (
"io"
"net/http"
)
// Option is an option to alter the behavior of [httpio] functionality.
type Option interface{ option() }
// WithClient specifies the [http.Client] to use in client-initiated requests.
// It only affects [Do], [Get], [Post], [Put], and [Delete].
// It has no effect on [Handler].
func WithClient(c *http.Client) Option {
panic("not implemented")
}
// WithMarshaler specifies an marshaler to use for a particular "Content-Type".
//
// For client-side requests (e.g., [Do], [Get], [Post], [Put], and [Delete]),
// the first specified encoder is used to specify the "Content-Type" and
// to marshal the HTTP request body.
//
// For server-side responses (e.g., [Handler]), the first match between
// the client-provided "Accept" header is used to select the encoder to use.
// If no match is found, the first specified encoder is used regardless.
//
// If no encoder is specified, by default the "application/json" content type
// is used with the [encoding/json] as the marshal implementation.
func WithMarshaler(contentType string, marshal func(io.Writer, any) error) Option {
panic("not implemented")
}
// WithUnmarshaler specifies an unmarshaler to use for a particular "Content-Type".
//
// For both client-side responses and server-side requests,
// the provided "Content-Type" header is used to select which decoder to use.
// If no match is found, the first specified encoder is used regardless.
func WithUnmarshaler(contentType string, unmarshal func(io.Reader, any) error) Option {
panic("not implemented")
}

View File

@@ -0,0 +1,10 @@
// Package urpath TODO
package urlpath
// option is an option to alter behavior of Marshal and Unmarshal.
// Currently, there are no defined options.
type option interface{ option() }
func Marshal(pattern string, val any, opts ...option) (path string, err error)
func Unmarshal(pattern, path string, val any, opts ...option) (err error)

View File

@@ -0,0 +1,10 @@
// Package urlquery TODO
package urlquery
// option is an option to alter behavior of Marshal and Unmarshal.
// Currently, there are no defined options.
type option interface{ option() }
func Marshal(val any, opts ...option) (query string, err error)
func Unmarshal(query string, val any, opts ...option) (err error)

View File

@@ -12,6 +12,7 @@ import (
"net/netip"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
@@ -155,6 +156,13 @@ type firewallTweaker struct {
// stop makes fwProc exit when closed.
fwProcWriter io.WriteCloser
fwProcEncoder *json.Encoder
// The path to the 'netsh.exe' binary, populated during the first call
// to runFirewall.
//
// not protected by mu; netshPath is only mutated inside netshPathOnce
netshPathOnce sync.Once
netshPath string
}
func (ft *firewallTweaker) clear() { ft.set(nil, nil, nil) }
@@ -185,10 +193,43 @@ func (ft *firewallTweaker) set(cidrs []string, routes, localRoutes []netip.Prefi
go ft.doAsyncSet()
}
// getNetshPath returns the path that should be used to execute netsh.
//
// We've seen a report from a customer that we're triggering the "cannot run
// executable found relative to current directory" protection that was added to
// prevent running possibly attacker-controlled binaries. To mitigate this,
// first try looking up the path to netsh.exe in the System32 directory
// explicitly, and then fall back to the prior behaviour of passing "netsh" to
// os/exec.Command.
func (ft *firewallTweaker) getNetshPath() string {
ft.netshPathOnce.Do(func() {
// The default value is the old approach: just run "netsh" and
// let os/exec resolve that into a full path.
ft.netshPath = "netsh"
path, err := windows.KnownFolderPath(windows.FOLDERID_System, 0)
if err != nil {
ft.logf("getNetshPath: error getting FOLDERID_System: %v", err)
return
}
expath := filepath.Join(path, "netsh.exe")
if _, err := os.Stat(expath); err == nil {
ft.netshPath = expath
return
} else if !os.IsNotExist(err) {
ft.logf("getNetshPath: error checking for existence of %q: %v", expath, err)
}
// Keep default
})
return ft.netshPath
}
func (ft *firewallTweaker) runFirewall(args ...string) (time.Duration, error) {
t0 := time.Now()
args = append([]string{"advfirewall", "firewall"}, args...)
cmd := exec.Command("netsh", args...)
cmd := exec.Command(ft.getNetshPath(), args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
b, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -0,0 +1,19 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package router
import (
"path/filepath"
"testing"
)
func TestGetNetshPath(t *testing.T) {
ft := &firewallTweaker{
logf: t.Logf,
}
path := ft.getNetshPath()
if !filepath.IsAbs(path) {
t.Errorf("expected absolute path for netsh.exe: %q", path)
}
}