Compare commits

..

1 Commits

Author SHA1 Message Date
Irbe Krumina
d8dda0048e wip
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-11-13 17:50:45 +00:00
110 changed files with 743 additions and 3130 deletions

View File

@@ -49,13 +49,13 @@ jobs:
# Install a more recent Go that understands modern go.mod content.
- name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
with:
go-version-file: go.mod
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
- uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
with:
go-version-file: go.mod
cache: false

View File

@@ -80,7 +80,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -153,13 +153,13 @@ jobs:
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
with:
go-version-file: go.mod
cache: false
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -260,7 +260,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -319,7 +319,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -367,7 +367,7 @@ jobs:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -461,7 +461,7 @@ jobs:
run: |
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
- name: upload crash
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
with:
name: artifacts

View File

@@ -39,7 +39,7 @@ func runHealthz(addr string, h *healthz) {
log.Fatalf("error listening on the provided health endpoint address %q: %v", addr, err)
}
mux := http.NewServeMux()
mux.Handle("GET /healthz", h)
mux.Handle("/healthz", h)
log.Printf("Running healthcheck endpoint at %s/healthz", addr)
hs := &http.Server{Handler: mux}

View File

@@ -61,7 +61,7 @@ func deleteAuthKey(ctx context.Context, secretName string) error {
Path: "/data/authkey",
},
}
if err := kc.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, m); err != nil {
if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil {
if s, ok := err.(*kubeapi.Status); ok && s.Code == http.StatusUnprocessableEntity {
// This is kubernetes-ese for "the field you asked to
// delete already doesn't exist", aka no-op.
@@ -81,7 +81,7 @@ func initKubeClient(root string) {
kubeclient.SetRootPathForTesting(root)
}
var err error
kc, err = kubeclient.New("tailscale-container")
kc, err = kubeclient.New()
if err != nil {
log.Fatalf("Error creating kube client: %v", err)
}

View File

@@ -102,6 +102,7 @@ import (
"net/netip"
"os"
"os/signal"
"path"
"path/filepath"
"slices"
"strings"
@@ -178,14 +179,6 @@ func main() {
}
defer killTailscaled()
if cfg.LocalAddrPort != "" && cfg.MetricsEnabled {
m := &metrics{
lc: client,
debugEndpoint: cfg.DebugAddrPort,
}
runMetrics(cfg.LocalAddrPort, m)
}
if cfg.EnableForwardingOptimizations {
if err := client.SetUDPGROForwarding(bootCtx); err != nil {
log.Printf("[unexpected] error enabling UDP GRO forwarding: %v", err)
@@ -738,6 +731,7 @@ func tailscaledConfigFilePath() string {
}
cv, err := kubeutils.CapVerFromFileName(e.Name())
if err != nil {
log.Printf("skipping file %q in tailscaled config directory %q: %v", e.Name(), dir, err)
continue
}
if cv > maxCompatVer && cv <= tailcfg.CurrentCapabilityVersion {
@@ -745,9 +739,8 @@ func tailscaledConfigFilePath() string {
}
}
if maxCompatVer == -1 {
log.Fatalf("no tailscaled config file found in %q for current capability version %d", dir, tailcfg.CurrentCapabilityVersion)
log.Fatalf("no tailscaled config file found in %q for current capability version %q", dir, tailcfg.CurrentCapabilityVersion)
}
filePath := filepath.Join(dir, kubeutils.TailscaledConfigFileName(maxCompatVer))
log.Printf("Using tailscaled config file %q to match current capability version %d", filePath, tailcfg.CurrentCapabilityVersion)
return filePath
log.Printf("Using tailscaled config file %q for capability version %q", maxCompatVer, tailcfg.CurrentCapabilityVersion)
return path.Join(dir, kubeutils.TailscaledConfigFileName(maxCompatVer))
}

View File

@@ -1,91 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"fmt"
"io"
"log"
"net"
"net/http"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
)
// metrics is a simple metrics HTTP server, if enabled it forwards requests to
// the tailscaled's LocalAPI usermetrics endpoint at /localapi/v0/usermetrics.
type metrics struct {
debugEndpoint string
lc *tailscale.LocalClient
}
func proxy(w http.ResponseWriter, r *http.Request, url string, do func(*http.Request) (*http.Response, error)) {
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to construct request: %s", err), http.StatusInternalServerError)
return
}
req.Header = r.Header.Clone()
resp, err := do(req)
if err != nil {
http.Error(w, fmt.Sprintf("failed to proxy request: %s", err), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
for key, val := range resp.Header {
for _, v := range val {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (m *metrics) handleMetrics(w http.ResponseWriter, r *http.Request) {
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi/v0/usermetrics"
proxy(w, r, localAPIURL, m.lc.DoLocalRequest)
}
func (m *metrics) handleDebug(w http.ResponseWriter, r *http.Request) {
if m.debugEndpoint == "" {
http.Error(w, "debug endpoint not configured", http.StatusNotFound)
return
}
debugURL := "http://" + m.debugEndpoint + r.URL.Path
proxy(w, r, debugURL, http.DefaultClient.Do)
}
// runMetrics runs a simple HTTP metrics endpoint at <addr>/metrics, forwarding
// requests to tailscaled's /localapi/v0/usermetrics API.
//
// In 1.78.x and 1.80.x, it also proxies debug paths to tailscaled's debug
// endpoint if configured to ease migration for a breaking change serving user
// metrics instead of debug metrics on the "metrics" port.
func runMetrics(addr string, m *metrics) {
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("error listening on the provided metrics endpoint address %q: %v", addr, err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /metrics", m.handleMetrics)
mux.HandleFunc("/debug/", m.handleDebug) // TODO(tomhjp): Remove for 1.82.0 release.
log.Printf("Running metrics endpoint at %s/metrics", addr)
ms := &http.Server{Handler: mux}
go func() {
if err := ms.Serve(ln); err != nil {
log.Fatalf("failed running metrics endpoint: %v", err)
}
}()
}

View File

@@ -389,7 +389,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
Path: fmt.Sprintf("/data/%s", egressservices.KeyEgressServices),
Value: bs,
}
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
if err := ep.kc.JSONPatchSecret(ctx, ep.stateSecret, []kubeclient.JSONPatch{patch}); err != nil {
return fmt.Errorf("error patching state Secret: %w", err)
}
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()

View File

@@ -67,18 +67,11 @@ type settings struct {
PodIP string
PodIPv4 string
PodIPv6 string
HealthCheckAddrPort string // TODO(tomhjp): use the local addr/port instead.
LocalAddrPort string
MetricsEnabled bool
DebugAddrPort string
HealthCheckAddrPort string
EgressSvcsCfgPath string
}
func configFromEnv() (*settings, error) {
defaultLocalAddrPort := ""
if v, ok := os.LookupEnv("POD_IP"); ok && v != "" {
defaultLocalAddrPort = fmt.Sprintf("%s:9002", v)
}
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
@@ -105,9 +98,6 @@ func configFromEnv() (*settings, error) {
PodIP: defaultEnv("POD_IP", ""),
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
LocalAddrPort: defaultEnv("TS_LOCAL_ADDR_PORT", defaultLocalAddrPort),
MetricsEnabled: defaultBool("TS_METRICS_ENABLED", false),
DebugAddrPort: defaultEnv("TS_DEBUG_ADDR_PORT", ""),
EgressSvcsCfgPath: defaultEnv("TS_EGRESS_SERVICES_CONFIG_PATH", ""),
}
podIPs, ok := os.LookupEnv("POD_IPS")
@@ -185,16 +175,6 @@ func (s *settings) validate() error {
return fmt.Errorf("error parsing TS_HEALTH_CHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
}
}
if s.LocalAddrPort != "" {
if _, err := netip.ParseAddrPort(s.LocalAddrPort); err != nil {
return fmt.Errorf("error parsing TS_LOCAL_ADDR_PORT value %q: %w", s.LocalAddrPort, err)
}
}
if s.DebugAddrPort != "" {
if _, err := netip.ParseAddrPort(s.DebugAddrPort); err != nil {
return fmt.Errorf("error parsing TS_DEBUG_ADDR_PORT value %q: %w", s.DebugAddrPort, err)
}
}
return nil
}

View File

@@ -90,12 +90,6 @@ func tailscaledArgs(cfg *settings) []string {
if cfg.TailscaledConfigFilePath != "" {
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
}
// Once enough proxy versions have been released for all the supported
// versions to understand this cfg setting, the operator can stop
// setting TS_TAILSCALED_EXTRA_ARGS for the debug flag.
if cfg.DebugAddrPort != "" && !strings.Contains(cfg.DaemonExtraArgs, cfg.DebugAddrPort) {
args = append(args, "--debug="+cfg.DebugAddrPort)
}
if cfg.DaemonExtraArgs != "" {
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
}

View File

@@ -27,6 +27,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/util/fastuuid
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
@@ -151,6 +152,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/fastuuid from tailscale.com/tsweb
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineiter from tailscale.com/hostinfo+
@@ -158,7 +160,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/mak from tailscale.com/health+
tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/rands from tailscale.com/tsweb
tailscale.com/util/set from tailscale.com/derp+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
@@ -243,6 +244,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/tls from golang.org/x/crypto/acme+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
@@ -274,7 +276,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from github.com/mdlayher/netlink+
math/rand/v2 from internal/concurrent+
math/rand/v2 from tailscale.com/util/fastuuid+
mime from github.com/prometheus/common/expfmt+
mime/multipart from net/http
mime/quotedprintable from mime/multipart

View File

@@ -109,7 +109,6 @@ func TestDeps(t *testing.T) {
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
"tailscale.com/net/packet": "not needed in derper",
"github.com/gaissmai/bart": "not needed in derper",
"database/sql/driver": "not needed in derper", // previously came in via github.com/google/uuid
},
}.Check(t)
}

View File

@@ -29,7 +29,6 @@ var (
tlsInterval = flag.Duration("tls-interval", 15*time.Second, "TLS probe interval")
bwInterval = flag.Duration("bw-interval", 0, "bandwidth probe interval (0 = no bandwidth probing)")
bwSize = flag.Int64("bw-probe-size-bytes", 1_000_000, "bandwidth probe size")
regionCode = flag.String("region-code", "", "probe only this region (e.g. 'lax'); if left blank, all regions will be probed")
)
func main() {
@@ -48,9 +47,6 @@ func main() {
if *bwInterval > 0 {
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize))
}
if *regionCode != "" {
opts = append(opts, prober.WithRegion(*regionCode))
}
dp, err := prober.DERP(p, *derpMapURL, opts...)
if err != nil {
log.Fatal(err)

View File

@@ -81,14 +81,6 @@ spec:
- name: PROXY_DEFAULT_CLASS
value: {{ .Values.proxyConfig.defaultProxyClass }}
{{- end }}
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
{{- with .Values.operatorConfig.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}

View File

@@ -1,4 +1,3 @@
{{- if .Values.ingressClass.enabled }}
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
@@ -7,4 +6,3 @@ metadata:
spec:
controller: tailscale.com/ts-ingress # controller name currently can not be changed
# parameters: {} # currently no parameters are supported
{{- end }}

View File

@@ -16,9 +16,6 @@ rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch", "get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding

View File

@@ -54,9 +54,6 @@ operatorConfig:
# - name: EXTRA_VAR2
# value: "value2"
# In the case that you already have a tailscale ingressclass in your cluster (or vcluster), you can disable the creation here
ingressClass:
enabled: true
# proxyConfig contains configuraton that will be applied to any ingress/egress
# proxies created by the operator.

View File

@@ -73,12 +73,7 @@ spec:
enable:
description: |-
Setting enable to true will make the proxy serve Tailscale metrics
at <pod-ip>:9002/metrics.
In 1.78.x and 1.80.x, this field also serves as the default value for
.spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both
fields will independently default to false.
at <pod-ip>:9001/debug/metrics.
Defaults to false.
type: boolean
statefulSet:
@@ -1254,25 +1249,6 @@ spec:
description: Configuration for the proxy container running tailscale.
type: object
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
type: object
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
env:
description: |-
List of environment variables to set in the container.
@@ -1577,25 +1553,6 @@ spec:
description: Configuration for the proxy init container that enables forwarding.
type: object
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
type: object
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
env:
description: |-
List of environment variables to set in the container.

View File

@@ -540,12 +540,7 @@ spec:
enable:
description: |-
Setting enable to true will make the proxy serve Tailscale metrics
at <pod-ip>:9002/metrics.
In 1.78.x and 1.80.x, this field also serves as the default value for
.spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both
fields will independently default to false.
at <pod-ip>:9001/debug/metrics.
Defaults to false.
type: boolean
required:
@@ -1721,25 +1716,6 @@ spec:
tailscaleContainer:
description: Configuration for the proxy container running tailscale.
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
type: object
env:
description: |-
List of environment variables to set in the container.
@@ -2044,25 +2020,6 @@ spec:
tailscaleInitContainer:
description: Configuration for the proxy init container that enables forwarding.
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
type: object
env:
description: |-
List of environment variables to set in the container.
@@ -4746,14 +4703,6 @@ rules:
- patch
- update
- watch
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
@@ -4826,14 +4775,6 @@ spec:
value: "false"
- name: PROXY_FIREWALL_MODE
value: auto
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
image: tailscale/k8s-operator:unstable
imagePullPolicy: Always
name: operator

View File

@@ -30,14 +30,6 @@ spec:
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
securityContext:
capabilities:
add:

View File

@@ -24,11 +24,3 @@ spec:
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid

View File

@@ -1388,7 +1388,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
confFileHash: "a67b5ad3ff605531c822327e8f1a23dd0846e1075b722c13402f7d5d0ba32ba2",
confFileHash: "362360188dac62bca8013c8134929fed8efd84b1f410c00873d14a05709b5647",
app: kubetypes.AppIngressProxy,
}
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
@@ -1399,7 +1399,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
mak.Set(&svc.Annotations, AnnotationHostname, "another-test")
})
o.hostname = "another-test"
o.confFileHash = "888a993ebee20ad6be99623b45015339de117946850cf1252bede0b570e04293"
o.confFileHash = "20db57cfabc3fc6490f6bb1dc85994e61d255cdfa2a56abb0141736e59f263ef"
expectReconciled(t, sr, "default", "test")
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
}

View File

@@ -160,10 +160,6 @@ func (pcr *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations fiel
violations = append(violations, field.TypeInvalid(field.NewPath("spec", "statefulSet", "pod", "tailscaleInitContainer", "image"), tc.Image, err.Error()))
}
}
if tc.Debug != nil {
violations = append(violations, field.TypeInvalid(field.NewPath("spec", "statefulSet", "pod", "tailscaleInitContainer", "debug"), tc.Debug, "debug settings cannot be configured on the init container"))
}
}
}
}

View File

@@ -135,56 +135,3 @@ func TestProxyClass(t *testing.T) {
expectReconciled(t, pcr, "", "test")
expectEvents(t, fr, expectedEvents)
}
func TestValidateProxyClass(t *testing.T) {
for name, tc := range map[string]struct {
pc *tsapi.ProxyClass
valid bool
}{
"empty": {
valid: true,
pc: &tsapi.ProxyClass{},
},
"debug_enabled_for_main_container": {
valid: true,
pc: &tsapi.ProxyClass{
Spec: tsapi.ProxyClassSpec{
StatefulSet: &tsapi.StatefulSet{
Pod: &tsapi.Pod{
TailscaleContainer: &tsapi.Container{
Debug: &tsapi.Debug{
Enable: true,
},
},
},
},
},
},
},
"debug_enabled_for_init_container": {
valid: false,
pc: &tsapi.ProxyClass{
Spec: tsapi.ProxyClassSpec{
StatefulSet: &tsapi.StatefulSet{
Pod: &tsapi.Pod{
TailscaleInitContainer: &tsapi.Container{
Debug: &tsapi.Debug{
Enable: true,
},
},
},
},
},
},
},
} {
t.Run(name, func(t *testing.T) {
pcr := &ProxyClassReconciler{}
err := pcr.validate(tc.pc)
valid := err == nil
if valid != tc.valid {
t.Errorf("expected valid=%v, got valid=%v, err=%v", tc.valid, valid, err)
}
})
}
}

View File

@@ -126,6 +126,15 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode, cfgHa
},
},
},
{
Name: "POD_NAME",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
// Secret is named after the pod.
FieldPath: "metadata.name",
},
},
},
{
Name: "TS_KUBE_SECRET",
Value: "$(POD_NAME)",
@@ -138,6 +147,10 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode, cfgHa
Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR",
Value: "/etc/tsconfig/$(POD_NAME)",
},
{
Name: "TS_USERSPACE",
Value: "false",
},
{
Name: "TS_INTERNAL_APP",
Value: kubetypes.AppProxyGroupEgress,
@@ -158,7 +171,7 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode, cfgHa
})
}
return append(c.Env, envs...)
return envs
}()
return ss, nil
@@ -202,15 +215,6 @@ func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role {
return secrets
}(),
},
{
APIGroups: []string{""},
Resources: []string{"events"},
Verbs: []string{
"create",
"patch",
"get",
},
},
},
}
}

View File

@@ -476,7 +476,7 @@ var proxyYaml []byte
//go:embed deploy/manifests/userspace-proxy.yaml
var userspaceProxyYaml []byte
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string, _ map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) (*appsv1.StatefulSet, error) {
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string, configs map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) (*appsv1.StatefulSet, error) {
ss := new(appsv1.StatefulSet)
if sts.ServeConfig != nil && sts.ForwardClusterTrafficViaL7IngressProxy != true { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding
if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil {
@@ -521,6 +521,11 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Name: "TS_KUBE_SECRET",
Value: proxySecret,
},
corev1.EnvVar{
// Old tailscaled config key is still used for backwards compatibility.
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
Value: "/etc/tsconfig/tailscaled",
},
corev1.EnvVar{
// New style is in the form of cap-<capability-version>.hujson.
Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR",
@@ -666,42 +671,24 @@ func mergeStatefulSetLabelsOrAnnots(current, custom map[string]string, managed [
return custom
}
func debugSetting(pc *tsapi.ProxyClass) bool {
if pc == nil ||
pc.Spec.StatefulSet == nil ||
pc.Spec.StatefulSet.Pod == nil ||
pc.Spec.StatefulSet.Pod.TailscaleContainer == nil ||
pc.Spec.StatefulSet.Pod.TailscaleContainer.Debug == nil {
// This default will change to false in 1.82.0.
return pc.Spec.Metrics != nil && pc.Spec.Metrics.Enable
}
return pc.Spec.StatefulSet.Pod.TailscaleContainer.Debug.Enable
}
func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet, stsCfg *tailscaleSTSConfig, logger *zap.SugaredLogger) *appsv1.StatefulSet {
if pc == nil || ss == nil {
return ss
}
metricsEnabled := pc.Spec.Metrics != nil && pc.Spec.Metrics.Enable
debugEnabled := debugSetting(pc)
if metricsEnabled || debugEnabled {
isEgress := stsCfg != nil && (stsCfg.TailnetTargetFQDN != "" || stsCfg.TailnetTargetIP != "")
isForwardingL7Ingress := stsCfg != nil && stsCfg.ForwardClusterTrafficViaL7IngressProxy
if isEgress {
if stsCfg != nil && pc.Spec.Metrics != nil && pc.Spec.Metrics.Enable {
if stsCfg.TailnetTargetFQDN == "" && stsCfg.TailnetTargetIP == "" && !stsCfg.ForwardClusterTrafficViaL7IngressProxy {
enableMetrics(ss)
} else if stsCfg.ForwardClusterTrafficViaL7IngressProxy {
// TODO (irbekrm): fix this
// For Ingress proxies that have been configured with
// tailscale.com/experimental-forward-cluster-traffic-via-ingress
// annotation, all cluster traffic is forwarded to the
// Ingress backend(s).
logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for egress proxies.")
} else if isForwardingL7Ingress {
logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for Ingress proxies that accept cluster traffic.")
} else {
// TODO (irbekrm): fix this
// For egress proxies, currently all cluster traffic is forwarded to the tailnet target.
logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for Ingress proxies that accept cluster traffic.")
} else {
enableEndpoints(ss, metricsEnabled, debugEnabled)
}
}
@@ -779,58 +766,16 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet,
return ss
}
func enableEndpoints(ss *appsv1.StatefulSet, metrics, debug bool) {
func enableMetrics(ss *appsv1.StatefulSet) {
for i, c := range ss.Spec.Template.Spec.Containers {
if c.Name == "tailscale" {
if debug {
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env,
// Serve tailscaled's debug metrics on on
// <pod-ip>:9001/debug/metrics. If we didn't specify Pod IP
// here, the proxy would, in some cases, also listen to its
// Tailscale IP- we don't want folks to start relying on this
// side-effect as a feature.
corev1.EnvVar{
Name: "TS_DEBUG_ADDR_PORT",
Value: "$(POD_IP):9001",
},
// TODO(tomhjp): Can remove this env var once 1.76.x is no
// longer supported.
corev1.EnvVar{
Name: "TS_TAILSCALED_EXTRA_ARGS",
Value: "--debug=$(TS_DEBUG_ADDR_PORT)",
},
)
ss.Spec.Template.Spec.Containers[i].Ports = append(ss.Spec.Template.Spec.Containers[i].Ports,
corev1.ContainerPort{
Name: "debug",
Protocol: "TCP",
ContainerPort: 9001,
},
)
}
if metrics {
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env,
// Serve client metrics on <pod-ip>:9002/metrics.
corev1.EnvVar{
Name: "TS_LOCAL_ADDR_PORT",
Value: "$(POD_IP):9002",
},
corev1.EnvVar{
Name: "TS_METRICS_ENABLED",
Value: "true",
},
)
ss.Spec.Template.Spec.Containers[i].Ports = append(ss.Spec.Template.Spec.Containers[i].Ports,
corev1.ContainerPort{
Name: "metrics",
Protocol: "TCP",
ContainerPort: 9002,
},
)
}
// Serve metrics on on <pod-ip>:9001/debug/metrics. If
// we didn't specify Pod IP here, the proxy would, in
// some cases, also listen to its Tailscale IP- we don't
// want folks to start relying on this side-effect as a
// feature.
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env, corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(POD_IP):9001"})
ss.Spec.Template.Spec.Containers[i].Ports = append(ss.Spec.Template.Spec.Containers[i].Ports, corev1.ContainerPort{Name: "metrics", Protocol: "TCP", HostPort: 9001, ContainerPort: 9001})
break
}
}
@@ -844,9 +789,15 @@ func readAuthKey(secret *corev1.Secret, key string) (*string, error) {
return origConf.AuthKey, nil
}
// tailscaledConfig takes a proxy config, a newly generated auth key if generated and a Secret with the previous proxy
// state and auth key and returns tailscaled config files for currently supported proxy versions and a hash of that
// configuration.
// tailscaledConfig takes a proxy config, a newly generated auth key if
// generated and a Secret with the previous proxy state and auth key and
// returns tailscaled configuration and a hash of that configuration.
//
// As of 2024-05-09 it also returns legacy tailscaled config without the
// later added NoStatefulFilter field to support proxies older than cap95.
// TODO (irbekrm): remove the legacy config once we no longer need to support
// versions older than cap94,
// https://tailscale.com/kb/1236/kubernetes-operator#operator-and-proxies
func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *corev1.Secret) (tailscaledConfigs, error) {
conf := &ipn.ConfigVAlpha{
Version: "alpha0",
@@ -895,6 +846,10 @@ func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *co
// AppConnector config option is only understood by clients of capver 107 and newer.
conf.AppConnector = nil
capVerConfigs[95] = *conf
// StatefulFiltering is only understood by clients of capver 95 and newer.
conf.NoStatefulFiltering.Clear()
capVerConfigs[94] = *conf
return capVerConfigs, nil
}

View File

@@ -125,26 +125,10 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
},
},
}
proxyClassWithMetricsDebug := func(metrics bool, debug *bool) *tsapi.ProxyClass {
return &tsapi.ProxyClass{
Spec: tsapi.ProxyClassSpec{
Metrics: &tsapi.Metrics{Enable: metrics},
StatefulSet: func() *tsapi.StatefulSet {
if debug == nil {
return nil
}
return &tsapi.StatefulSet{
Pod: &tsapi.Pod{
TailscaleContainer: &tsapi.Container{
Debug: &tsapi.Debug{Enable: *debug},
},
},
}
}(),
},
}
proxyClassMetrics := &tsapi.ProxyClass{
Spec: tsapi.ProxyClassSpec{
Metrics: &tsapi.Metrics{Enable: true},
},
}
var userspaceProxySS, nonUserspaceProxySS appsv1.StatefulSet
@@ -200,7 +184,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
gotSS := applyProxyClassToStatefulSet(proxyClassAllOpts, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with all fields set to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
t.Fatalf("Unexpected result applying ProxyClass with all fields set to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
}
// 2. Test that a ProxyClass with custom labels and annotations for
@@ -213,7 +197,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
wantSS.Spec.Template.Annotations = proxyClassJustLabels.Spec.StatefulSet.Pod.Annotations
gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
}
// 3. Test that a ProxyClass with all fields set gets correctly applied
@@ -237,7 +221,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
wantSS.Spec.Template.Spec.Containers[0].Image = "ghcr.io/my-repo/tailscale:v0.01testsomething"
gotSS = applyProxyClassToStatefulSet(proxyClassAllOpts, userspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with all options to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
t.Fatalf("Unexpected result applying ProxyClass with all options to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
}
// 4. Test that a ProxyClass with custom labels and annotations gets correctly applied
@@ -249,48 +233,16 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
wantSS.Spec.Template.Annotations = proxyClassJustLabels.Spec.StatefulSet.Pod.Annotations
gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, userspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
}
// 5. Metrics enabled defaults to enabling both metrics and debug.
// 5. Test that a ProxyClass with metrics enabled gets correctly applied to a StatefulSet.
wantSS = nonUserspaceProxySS.DeepCopy()
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env,
corev1.EnvVar{Name: "TS_DEBUG_ADDR_PORT", Value: "$(POD_IP):9001"},
corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(TS_DEBUG_ADDR_PORT)"},
corev1.EnvVar{Name: "TS_LOCAL_ADDR_PORT", Value: "$(POD_IP):9002"},
corev1.EnvVar{Name: "TS_METRICS_ENABLED", Value: "true"},
)
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{
{Name: "debug", Protocol: "TCP", ContainerPort: 9001},
{Name: "metrics", Protocol: "TCP", ContainerPort: 9002},
}
gotSS = applyProxyClassToStatefulSet(proxyClassWithMetricsDebug(true, nil), nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(POD_IP):9001"})
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{{Name: "metrics", Protocol: "TCP", ContainerPort: 9001, HostPort: 9001}}
gotSS = applyProxyClassToStatefulSet(proxyClassMetrics, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
}
// 6. Enable _just_ metrics by explicitly disabling debug.
wantSS = nonUserspaceProxySS.DeepCopy()
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env,
corev1.EnvVar{Name: "TS_LOCAL_ADDR_PORT", Value: "$(POD_IP):9002"},
corev1.EnvVar{Name: "TS_METRICS_ENABLED", Value: "true"},
)
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{{Name: "metrics", Protocol: "TCP", ContainerPort: 9002}}
gotSS = applyProxyClassToStatefulSet(proxyClassWithMetricsDebug(true, ptr.To(false)), nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
}
// 7. Enable _just_ debug without metrics.
wantSS = nonUserspaceProxySS.DeepCopy()
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env,
corev1.EnvVar{Name: "TS_DEBUG_ADDR_PORT", Value: "$(POD_IP):9001"},
corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(TS_DEBUG_ADDR_PORT)"},
)
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{{Name: "debug", Protocol: "TCP", ContainerPort: 9001}}
gotSS = applyProxyClassToStatefulSet(proxyClassWithMetricsDebug(false, ptr.To(true)), nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
t.Errorf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
t.Fatalf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
}
}

View File

@@ -70,9 +70,8 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "POD_IP", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "status.podIP"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.name"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "POD_UID", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.uid"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
{Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
},
SecurityContext: &corev1.SecurityContext{
@@ -230,9 +229,8 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "true"},
{Name: "POD_IP", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "status.podIP"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.name"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "POD_UID", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.uid"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
{Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"},
{Name: "TS_INTERNAL_APP", Value: opts.app},
@@ -406,6 +404,12 @@ func expectedSecret(t *testing.T, cl client.Client, opts configOpts) *corev1.Sec
if err != nil {
t.Fatalf("error marshalling tailscaled config")
}
conf.NoStatefulFiltering.Clear()
b, err := json.Marshal(conf)
if err != nil {
t.Fatalf("error marshalling tailscaled config")
}
mak.Set(&s.StringData, "tailscaled", string(b))
mak.Set(&s.StringData, "cap-95.hujson", string(bn))
mak.Set(&s.StringData, "cap-107.hujson", string(bnn))
labels := map[string]string{
@@ -658,6 +662,18 @@ func removeTargetPortsFromSvc(svc *corev1.Service) {
func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) {
return func(secret *corev1.Secret) {
t.Helper()
if len(secret.StringData["tailscaled"]) != 0 {
conf := &ipn.ConfigVAlpha{}
if err := json.Unmarshal([]byte(secret.StringData["tailscaled"]), conf); err != nil {
t.Fatalf("error unmarshalling 'tailscaled' contents: %v", err)
}
conf.AuthKey = nil
b, err := json.Marshal(conf)
if err != nil {
t.Fatalf("error marshalling updated 'tailscaled' config: %v", err)
}
mak.Set(&secret.StringData, "tailscaled", string(b))
}
if len(secret.StringData["cap-95.hujson"]) != 0 {
conf := &ipn.ConfigVAlpha{}
if err := json.Unmarshal([]byte(secret.StringData["cap-95.hujson"]), conf); err != nil {

View File

@@ -130,15 +130,6 @@ func tsrRole(tsr *tsapi.Recorder, namespace string) *rbacv1.Role {
fmt.Sprintf("%s-0", tsr.Name), // Contains the node state.
},
},
{
APIGroups: []string{""},
Resources: []string{"events"},
Verbs: []string{
"get",
"create",
"patch",
},
},
},
}
}
@@ -212,14 +203,6 @@ func env(tsr *tsapi.Recorder) []corev1.EnvVar {
},
},
},
{
Name: "POD_UID",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
FieldPath: "metadata.uid",
},
},
},
{
Name: "TS_STATE",
Value: "kube:$(POD_NAME)",

View File

@@ -8,6 +8,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/google/uuid from tailscale.com/util/fastuuid
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
github.com/prometheus/client_model/go from github.com/prometheus/client_golang/prometheus+
@@ -73,9 +74,9 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
tailscale.com/util/ctxkey from tailscale.com/tsweb+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/tailcfg
tailscale.com/util/fastuuid from tailscale.com/tsweb
tailscale.com/util/lineiter from tailscale.com/version/distro
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/rands from tailscale.com/tsweb
tailscale.com/util/slicesx from tailscale.com/tailcfg
tailscale.com/util/vizerror from tailscale.com/tailcfg+
tailscale.com/version from tailscale.com/envknob+
@@ -132,6 +133,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
crypto/tls from net/http+
crypto/x509 from crypto/tls
crypto/x509/pkix from crypto/x509
database/sql/driver from github.com/google/uuid
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
@@ -162,7 +164,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from math/big+
math/rand/v2 from internal/concurrent+
math/rand/v2 from tailscale.com/util/fastuuid+
mime from github.com/prometheus/common/expfmt+
mime/multipart from net/http
mime/quotedprintable from mime/multipart

View File

@@ -93,13 +93,8 @@ func Run(args []string) (err error) {
args = CleanUpArgs(args)
if len(args) == 1 {
switch args[0] {
case "-V", "--version":
args = []string{"version"}
case "help":
args = []string{"--help"}
}
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
args = []string{"version"}
}
var warnOnce sync.Once

View File

@@ -9,7 +9,6 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"net/netip"
"reflect"
"strings"
@@ -1481,33 +1480,3 @@ func TestParseNLArgs(t *testing.T) {
})
}
}
func TestHelpAlias(t *testing.T) {
var stdout, stderr bytes.Buffer
tstest.Replace[io.Writer](t, &Stdout, &stdout)
tstest.Replace[io.Writer](t, &Stderr, &stderr)
gotExit0 := false
defer func() {
if !gotExit0 {
t.Error("expected os.Exit(0) to be called")
return
}
if !strings.Contains(stderr.String(), "SUBCOMMANDS") {
t.Errorf("expected help output to contain SUBCOMMANDS; got stderr=%q; stdout=%q", stderr.String(), stdout.String())
}
}()
defer func() {
if e := recover(); e != nil {
if strings.Contains(fmt.Sprint(e), "unexpected call to os.Exit(0)") {
gotExit0 = true
} else {
t.Errorf("unexpected panic: %v", e)
}
}
}()
err := Run([]string{"help"})
if err != nil {
t.Fatalf("Run: %v", err)
}
}

View File

@@ -36,7 +36,6 @@ import (
"tailscale.com/hostinfo"
"tailscale.com/internal/noiseconn"
"tailscale.com/ipn"
"tailscale.com/net/netmon"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tshttpproxy"
"tailscale.com/paths"
@@ -214,7 +213,6 @@ var debugCmd = &ffcli.Command{
fs := newFlagSet("watch-ipn")
fs.BoolVar(&watchIPNArgs.netmap, "netmap", true, "include netmap in messages")
fs.BoolVar(&watchIPNArgs.initial, "initial", false, "include initial status")
fs.BoolVar(&watchIPNArgs.rateLimit, "rate-limit", true, "rate limit messags")
fs.BoolVar(&watchIPNArgs.showPrivateKey, "show-private-key", false, "include node private key in printed netmap")
fs.IntVar(&watchIPNArgs.count, "count", 0, "exit after printing this many statuses, or 0 to keep going forever")
return fs
@@ -502,7 +500,6 @@ var watchIPNArgs struct {
netmap bool
initial bool
showPrivateKey bool
rateLimit bool
count int
}
@@ -514,9 +511,6 @@ func runWatchIPN(ctx context.Context, args []string) error {
if !watchIPNArgs.showPrivateKey {
mask |= ipn.NotifyNoPrivateKeys
}
if watchIPNArgs.rateLimit {
mask |= ipn.NotifyRateLimit
}
watcher, err := localClient.WatchIPNBus(ctx, mask)
if err != nil {
return err
@@ -851,11 +845,6 @@ func runTS2021(ctx context.Context, args []string) error {
logf = log.Printf
}
netMon, err := netmon.New(logger.WithPrefix(logf, "netmon: "))
if err != nil {
return fmt.Errorf("creating netmon: %w", err)
}
noiseDialer := &controlhttp.Dialer{
Hostname: ts2021Args.host,
HTTPPort: "80",
@@ -865,7 +854,6 @@ func runTS2021(ctx context.Context, args []string) error {
ProtocolVersion: uint16(ts2021Args.version),
Dialer: dialFunc,
Logf: logf,
NetMon: netMon,
}
const tries = 2
for i := range tries {

View File

@@ -17,18 +17,11 @@ import (
)
var (
riskTypes []string
riskLoseSSH = registerRiskType("lose-ssh")
riskMacAppConnector = registerRiskType("mac-app-connector")
riskAll = registerRiskType("all")
riskTypes []string
riskLoseSSH = registerRiskType("lose-ssh")
riskAll = registerRiskType("all")
)
const riskMacAppConnectorMessage = `
You are trying to configure an app connector on macOS, which is not officially supported due to system limitations. This may result in performance and reliability issues.
Do not use a macOS app connector for any mission-critical purposes. For the best experience, Linux is the only recommended platform for app connectors.
`
func registerRiskType(riskType string) string {
riskTypes = append(riskTypes, riskType)
return riskType

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"net/netip"
"os/exec"
"runtime"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
@@ -204,12 +203,6 @@ func runSet(ctx context.Context, args []string) (retErr error) {
}
}
if runtime.GOOS == "darwin" && maskedPrefs.AppConnector.Advertise {
if err := presentRiskToUser(riskMacAppConnector, riskMacAppConnectorMessage, setArgs.acceptedRisks); err != nil {
return err
}
}
if maskedPrefs.RunSSHSet {
wantSSH, haveSSH := maskedPrefs.RunSSH, curPrefs.RunSSH
if err := presentSSHToggleRisk(wantSSH, haveSSH, setArgs.acceptedRisks); err != nil {

View File

@@ -98,9 +98,9 @@ func printPolicySettings(policy *setting.Snapshot) {
origin = o.String()
}
if err := setting.Error(); err != nil {
fmt.Fprintf(w, "%s\t%s\t\t{%v}\n", k, origin, err)
fmt.Fprintf(w, "%s\t%s\t\t{%s}\n", k, origin, err)
} else {
fmt.Fprintf(w, "%s\t%s\t%v\t\n", k, origin, setting.Value())
fmt.Fprintf(w, "%s\t%s\t%s\t\n", k, origin, setting.Value())
}
}
w.Flush()

View File

@@ -379,12 +379,6 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
return false, nil, err
}
if runtime.GOOS == "darwin" && env.upArgs.advertiseConnector {
if err := presentRiskToUser(riskMacAppConnector, riskMacAppConnectorMessage, env.upArgs.acceptedRisks); err != nil {
return false, nil, err
}
}
if env.upArgs.forceReauth && isSSHOverTailscale() {
if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will result in your SSH session disconnecting.`, env.upArgs.acceptedRisks); err != nil {
return false, nil, err

View File

@@ -134,13 +134,14 @@ func runWindowsService(pol *logpolicy.Policy) error {
logger.Logf(log.Printf).JSON(1, "SupportInfo", osdiag.SupportInfo(osdiag.LogSupportInfoReasonStartup))
}()
if syslog, err := eventlog.Open(serviceName); err == nil {
syslogf = func(format string, args ...any) {
if logSCMInteractions, _ := syspolicy.GetBoolean(syspolicy.LogSCMInteractions, false); logSCMInteractions {
if logSCMInteractions, _ := syspolicy.GetBoolean(syspolicy.LogSCMInteractions, false); logSCMInteractions {
syslog, err := eventlog.Open(serviceName)
if err == nil {
syslogf = func(format string, args ...any) {
syslog.Info(0, fmt.Sprintf(format, args...))
}
defer syslog.Close()
}
defer syslog.Close()
}
syslogf("Service entering svc.Run")
@@ -159,7 +160,10 @@ func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, ch
changes <- svc.Status{State: svc.StartPending}
syslogf("Service start pending")
svcAccepts := svc.AcceptStop | svc.AcceptSessionChange
svcAccepts := svc.AcceptStop
if flushDNSOnSessionUnlock, _ := syspolicy.GetBoolean(syspolicy.FlushDNSOnSessionUnlock, false); flushDNSOnSessionUnlock {
svcAccepts |= svc.AcceptSessionChange
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -367,15 +371,13 @@ func handleSessionChange(chgRequest svc.ChangeRequest) {
return
}
if flushDNSOnSessionUnlock, _ := syspolicy.GetBoolean(syspolicy.FlushDNSOnSessionUnlock, false); flushDNSOnSessionUnlock {
log.Printf("Received WTS_SESSION_UNLOCK event, initiating DNS flush.")
go func() {
err := dns.Flush()
if err != nil {
log.Printf("Error flushing DNS on session unlock: %v", err)
}
}()
}
log.Printf("Received WTS_SESSION_UNLOCK event, initiating DNS flush.")
go func() {
err := dns.Flush()
if err != nil {
log.Printf("Error flushing DNS on session unlock: %v", err)
}
}()
}
var (

View File

@@ -564,6 +564,12 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
case opt.URL != "":
// Nothing.
case regen || persist.PrivateNodeKey.IsZero():
if regen {
c.logf("TEST: need to regenerate")
} else {
c.logf("TEST: private node key is zero, persist is %v", persist)
c.logf("TEST: private node key is zero, persist is %v", persist)
}
c.logf("Generating a new nodekey.")
persist.OldPrivateNodeKey = persist.PrivateNodeKey
tryingNewKey = key.NewNode()

View File

@@ -17,6 +17,7 @@ import (
"golang.org/x/net/http2"
"tailscale.com/control/controlhttp"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/internal/noiseconn"
"tailscale.com/net/dnscache"
@@ -29,6 +30,7 @@ import (
"tailscale.com/util/mak"
"tailscale.com/util/multierr"
"tailscale.com/util/singleflight"
"tailscale.com/util/testenv"
)
// NoiseClient provides a http.Client to connect to tailcontrol over
@@ -105,6 +107,11 @@ type NoiseOpts struct {
DialPlan func() *tailcfg.ControlDialPlan
}
// controlIsPlaintext is whether we should assume that the controlplane is only accessible
// over plaintext HTTP (as the first hop, before the ts2021 encryption begins).
// This is used by some tests which don't have a real TLS certificate.
var controlIsPlaintext = envknob.RegisterBool("TS_CONTROL_IS_PLAINTEXT_HTTP")
// NewNoiseClient returns a new noiseClient for the provided server and machine key.
// serverURL is of the form https://<host>:<port> (no trailing slash).
//
@@ -122,7 +129,7 @@ func NewNoiseClient(opts NoiseOpts) (*NoiseClient, error) {
if u.Scheme == "http" {
httpPort = port
httpsPort = "443"
if u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" {
if (testenv.InTest() || controlIsPlaintext()) && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost") {
httpsPort = ""
}
} else {

View File

@@ -13,6 +13,7 @@ import (
"crypto/x509"
"errors"
"fmt"
"sync"
"time"
"github.com/tailscale/certstore"
@@ -21,6 +22,11 @@ import (
"tailscale.com/util/syspolicy"
)
var getMachineCertificateSubjectOnce struct {
sync.Once
v string // Subject of machine certificate to search for
}
// getMachineCertificateSubject returns the exact name of a Subject that needs
// to be present in an identity's certificate chain to sign a RegisterRequest,
// formatted as per pkix.Name.String(). The Subject may be that of the identity
@@ -31,8 +37,11 @@ import (
//
// Example: "CN=Tailscale Inc Test Root CA,OU=Tailscale Inc Test Certificate Authority,O=Tailscale Inc,ST=ON,C=CA"
func getMachineCertificateSubject() string {
machineCertSubject, _ := syspolicy.GetString(syspolicy.MachineCertificateSubject, "")
return machineCertSubject
getMachineCertificateSubjectOnce.Do(func() {
getMachineCertificateSubjectOnce.v, _ = syspolicy.GetString(syspolicy.MachineCertificateSubject, "")
})
return getMachineCertificateSubjectOnce.v
}
var (

View File

@@ -246,7 +246,7 @@ 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
}
merr := multierr.New(multierr.DeduplicateContextErrors(errs)...)
merr := multierr.New(errs...)
// If we get here, then we didn't get anywhere with our dial plan; fall back to just using DNS.
a.logf("controlhttp: failed dialing using DialPlan, falling back to DNS; errs=%s", merr.Error())

View File

@@ -76,8 +76,6 @@ type Dialer struct {
// dropped.
Logf logger.Logf
// NetMon is the [netmon.Monitor] to use for this Dialer. It must be
// non-nil.
NetMon *netmon.Monitor
// HealthTracker, if non-nil, is the health tracker to use.

View File

@@ -25,7 +25,6 @@ import (
"tailscale.com/control/controlbase"
"tailscale.com/control/controlhttp/controlhttpcommon"
"tailscale.com/control/controlhttp/controlhttpserver"
"tailscale.com/health"
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/net/socks5"
@@ -229,7 +228,6 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
omitCertErrorLogging: true,
testFallbackDelay: fallbackDelay,
Clock: clock,
HealthTracker: new(health.Tracker),
}
if param.httpInDial {
@@ -731,7 +729,6 @@ func TestDialPlan(t *testing.T) {
omitCertErrorLogging: true,
testFallbackDelay: 50 * time.Millisecond,
Clock: clock,
HealthTracker: new(health.Tracker),
}
conn, err := a.dial(ctx)

View File

@@ -44,14 +44,6 @@ spec:
value: "{{TS_DEST_IP}}"
- name: TS_AUTH_ONCE
value: "true"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
securityContext:
capabilities:
add:

View File

@@ -13,6 +13,3 @@ rules:
resourceNames: ["{{TS_KUBE_SECRET}}"]
resources: ["secrets"]
verbs: ["get", "update", "patch"]
- apiGroups: [""] # "" indicates the core API group
resources: ["events"]
verbs: ["get", "create", "patch"]

View File

@@ -26,14 +26,6 @@ spec:
name: tailscale-auth
key: TS_AUTHKEY
optional: true
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
securityContext:
capabilities:
add:

View File

@@ -28,14 +28,6 @@ spec:
optional: true
- name: TS_ROUTES
value: "{{TS_ROUTES}}"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
securityContext:
capabilities:
add:

View File

@@ -27,11 +27,3 @@ spec:
name: tailscale-auth
key: TS_AUTHKEY
optional: true
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid

View File

@@ -15,18 +15,16 @@
<string id="SINCE_V1_58">Tailscale version 1.58.0 and later</string>
<string id="SINCE_V1_62">Tailscale version 1.62.0 and later</string>
<string id="SINCE_V1_74">Tailscale version 1.74.0 and later</string>
<string id="SINCE_V1_78">Tailscale version 1.78.0 and later</string>
<string id="Tailscale_Category">Tailscale</string>
<string id="UI_Category">UI customization</string>
<string id="Settings_Category">Settings</string>
<string id="LoginURL">Require using a specific Tailscale coordination server</string>
<string id="LoginURL_Help"><![CDATA[This policy can be used to require the use of a particular Tailscale coordination server.
See https://tailscale.com/kb/1315/mdm-keys#set-a-custom-control-server-url for more details.
If you enable this policy, set it to the URL of your coordination server, beginning with https:// and ending with no trailing slash. If blank or "https://controlplane.tailscale.com", the default coordination server will be required.
If you configure this policy, set it to the URL of your coordination server, beginning with https:// and ending with no trailing slash. If blank or "https://controlplane.tailscale.com", the default coordination server will be required.
If you disable or do not configure this policy, the Tailscale SaaS coordination server will be used by default, but a non-standard Tailscale coordination server can be configured using the CLI.
See https://tailscale.com/kb/1315/mdm-keys#set-a-custom-control-server-url for more details.]]></string>
If you disable this policy, the Tailscale SaaS coordination server will be used by default, but a non-standard Tailscale coordination server can be configured using the CLI.]]></string>
<string id="LogTarget">Require using a specific Tailscale log server</string>
<string id="LogTarget_Help"><![CDATA[This policy can be used to require the use of a non-standard log server.
Please note that using a non-standard log server will limit Tailscale Support's ability to diagnose problems.
@@ -36,16 +34,15 @@ If you configure this policy, set it to the URL of your log server, beginning wi
If you disable this policy, the Tailscale standard log server will be used by default, but a non-standard Tailscale log server can be configured using the TS_LOG_TARGET environment variable.]]></string>
<string id="Tailnet">Specify which Tailnet should be used for Login</string>
<string id="Tailnet_Help"><![CDATA[This policy can be used to suggest or require a specific tailnet when opening the login page.
See https://tailscale.com/kb/1315/mdm-keys#set-a-suggested-or-required-tailnet for more details.
To suggest a tailnet at login time, set this to the name of the tailnet, as shown in the top-left of the admin panel, such as "example.com". That tailnet's SSO button will be shown prominently, along with the option to select a different tailnet.
To require logging in to a particular tailnet, add the "required:" prefix, such as "required:example.com". The result is similar to the suggested tailnet but there will be no option to choose a different tailnet.
If you enable this policy, set it to the name of the tailnet, possibly with the "required:" prefix, as described above.
If you configure this policy, set it to the name of the tailnet, possibly with the "required:" prefix, as described above.
If you disable or do not configure this policy, the standard login page will be used.
See https://tailscale.com/kb/1315/mdm-keys#set-a-suggested-or-required-tailnet for more details.]]></string>
If you disable this policy, the standard login page will be used.]]></string>
<string id="AuthKey">Specify the auth key to authenticate devices without user interaction</string>
<string id="AuthKey_Help"><![CDATA[This policy allows specifying the default auth key to be used when registering new devices without requiring sign-in via a web browser, unless the user specifies a different auth key via the CLI.
@@ -55,101 +52,85 @@ While MDM solutions tend to offer better control over who can access the policy
Only consider this option after carefully reviewing the organization's security posture. For example, ensure you configure the auth keys specifically for the tag of the device and that access control policies only grant necessary access between the tailnet and the tagged device. Additionally, consider using short-lived auth keys, one-time auth keys (with one GPO/MDM configuration per device), Device Approval, and/or Tailnet lock to minimize risk. If you suspect an auth key has been compromised, revoke the auth key immediately.
If you enable this policy setting and specify an auth key, it will be used to authenticate the device unless the device is already logged in or an auth key is explicitly specified via the CLI.
If you configure this policy setting and specify an auth key, it will be used to authenticate the device unless the device is already logged in or an auth key is explicitly specified via the CLI.
If you disable or do not configure this policy setting, an interactive user login will be required..
See https://tailscale.com/kb/1315/mdm-keys#set-an-auth-key for more details.]]></string>
<string id="ExitNodeID">Require using a specific Exit Node</string>
<string id="ExitNodeID_Help"><![CDATA[This policy can be used to require always using the specified Exit Node whenever the Tailscale client is connected.
If you enable this policy, set it to the ID of an exit node. The ID is visible on the Machines page of the admin console, or can be queried using the Tailscale API. If the specified exit node is unavailable, this device will have no Internet access unless Tailscale is disconnected. Alternatively, you can set it to "auto:any" (without quotes), which allows the Tailscale client to automatically select the most suitable exit node.
See https://tailscale.com/kb/1315/mdm-keys#force-an-exit-node-to-always-be-used and https://tailscale.com/kb/1103/exit-nodes for more details.
If you enable this policy, set it to the ID of an exit node. The ID is visible on the Machines page of the admin console, or can be queried using the Tailscale API. If the specified exit node is unavailable, this device will have no Internet access unless Tailscale is disconnected.
If you disable this policy or supply an empty exit node ID, then usage of exit nodes will be disallowed.
If you do not configure this policy, no exit node will be used by default but an exit node (if one is available and permitted by ACLs) can be chosen by the user if desired.
See https://tailscale.com/kb/1315/mdm-keys#force-an-exit-node-to-always-be-used and https://tailscale.com/kb/1103/exit-nodes for more details.]]></string>
<string id="AllowedSuggestedExitNodes">Limit automated Exit Node suggestions to specific nodes</string>
<string id="AllowedSuggestedExitNodes_Help"><![CDATA[This policy setting allows configuring a pool of exit nodes from which the Tailscale client will automatically select the most suitable suggested exit node when required. The suggested exit node is displayed in the GUI and CLI and is automatically selected and enforced when the "Require using a specific Exit Node" policy setting is enabled and set to "auto:any".
If you enable this policy setting, suggestions will be limited to exit nodes with the specified IDs. The IDs are visible on the Machines page of the admin console, or can be queried using the Tailscale API.
If you disable or do not configure this policy setting, no limitations will apply, and all available exit nodes will be considered when selecting the most suitable suggested node.
See https://tailscale.com/kb/1315/mdm-keys#suggest-allowed-forced-exit-nodes and https://tailscale.com/kb/1103/exit-nodes for more details.]]></string>
If you do not configure this policy, no exit node will be used by default but an exit node (if one is available and permitted by ACLs) can be chosen by the user if desired.]]></string>
<string id="AllowIncomingConnections">Allow incoming connections</string>
<string id="AllowIncomingConnections_Help"><![CDATA[This policy can be used to require that the Allow Incoming Connections setting is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-to-allow-incoming-connections and https://tailscale.com/kb/1072/client-preferences#allow-incoming-connections for more details.
If you enable this policy, then Allow Incoming Connections is always enabled and the menu option is hidden.
If you disable this policy, then Allow Incoming Connections is always disabled and the menu option is hidden.
If you do not configure this policy, then Allow Incoming Connections depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-to-allow-incoming-connections and https://tailscale.com/kb/1072/client-preferences#allow-incoming-connections for more details.]]></string>
If you do not configure this policy, then Allow Incoming Connections depends on what is selected in the Preferences submenu.]]></string>
<string id="UnattendedMode">Run Tailscale in Unattended Mode</string>
<string id="UnattendedMode_Help"><![CDATA[This policy can be used to require that the Run Unattended setting is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#set-unattended-mode and https://tailscale.com/kb/1088/run-unattended for more details.
If you enable this policy, then Run Unattended is always enabled and the menu option is hidden.
If you disable this policy, then Run Unattended is always disabled and the menu option is hidden.
If you do not configure this policy, then Run Unattended depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1315/mdm-keys#set-unattended-mode and https://tailscale.com/kb/1088/run-unattended for more details.]]></string>
If you do not configure this policy, then Run Unattended depends on what is selected in the Preferences submenu.]]></string>
<string id="ExitNodeAllowLANAccess">Allow Local Network Access when an Exit Node is in use</string>
<string id="ExitNodeAllowLANAccess_Help"><![CDATA[This policy can be used to require that the Allow Local Network Access setting is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#toggle-local-network-access-when-an-exit-node-is-in-use and https://tailscale.com/kb/1103/exit-nodes#step-4-use-the-exit-node for more details.
If you enable this policy, then Allow Local Network Access is always enabled and the menu option is hidden.
If you disable this policy, then Allow Local Network Access is always disabled and the menu option is hidden.
If you do not configure this policy, then Allow Local Network Access depends on what is selected in the Exit Node submenu.
See https://tailscale.com/kb/1315/mdm-keys#toggle-local-network-access-when-an-exit-node-is-in-use and https://tailscale.com/kb/1103/exit-nodes#step-4-use-the-exit-node for more details.]]></string>
If you do not configure this policy, then Allow Local Network Access depends on what is selected in the Exit Node submenu.]]></string>
<string id="UseTailscaleDNSSettings">Use Tailscale DNS Settings</string>
<string id="UseTailscaleDNSSettings_Help"><![CDATA[This policy can be used to require that Use Tailscale DNS is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-the-device-uses-tailscale-dns-settings for more details.
If you enable this policy, then Use Tailscale DNS is always enabled and the menu option is hidden.
If you disable this policy, then Use Tailscale DNS is always disabled and the menu option is hidden.
If you do not configure this policy, then Use Tailscale DNS depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-the-device-uses-tailscale-dns-settings for more details.]]></string>
If you do not configure this policy, then Use Tailscale DNS depends on what is selected in the Preferences submenu.]]></string>
<string id="UseTailscaleSubnets">Use Tailscale Subnets</string>
<string id="UseTailscaleSubnets_Help"><![CDATA[This policy can be used to require that Use Tailscale Subnets is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-the-device-accepts-tailscale-subnets or https://tailscale.com/kb/1019/subnets for more details.
If you enable this policy, then Use Tailscale Subnets is always enabled and the menu option is hidden.
If you disable this policy, then Use Tailscale Subnets is always disabled and the menu option is hidden.
If you do not configure this policy, then Use Tailscale Subnets depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1315/mdm-keys#set-whether-the-device-accepts-tailscale-subnets or https://tailscale.com/kb/1019/subnets for more details.]]></string>
If you do not configure this policy, then Use Tailscale Subnets depends on what is selected in the Preferences submenu.]]></string>
<string id="InstallUpdates">Automatically install updates</string>
<string id="InstallUpdates_Help"><![CDATA[This policy can be used to require that Automatically Install Updates is configured a certain way.
See https://tailscale.com/kb/1067/update#auto-updates for more details.
If you enable this policy, then Automatically Install Updates is always enabled and the menu option is hidden.
If you disable this policy, then Automatically Install Updates is always disabled and the menu option is hidden.
If you do not configure this policy, then Automatically Install Updates depends on what is selected in the Preferences submenu.
See https://tailscale.com/kb/1067/update#auto-updates for more details.]]></string>
If you do not configure this policy, then Automatically Install Updates depends on what is selected in the Preferences submenu.]]></string>
<string id="AdvertiseExitNode">Run Tailscale as an Exit Node</string>
<string id="AdvertiseExitNode_Help"><![CDATA[This policy can be used to require that Run Exit Node is configured a certain way.
See https://tailscale.com/kb/1103/exit-nodes for more details.
If you enable this policy, then Run Exit Node is always enabled and the menu option is hidden.
If you disable this policy, then Run Exit Node is always disabled and the menu option is hidden.
If you do not configure this policy, then Run Exit Node depends on what is selected in the Exit Node submenu.
See https://tailscale.com/kb/1103/exit-nodes for more details.]]></string>
<string id="AdminConsole">Show the "Admin Console" menu item</string>
<string id="AdminConsole_Help"><![CDATA[This policy can be used to show or hide the Admin Console item in the Tailscale Menu.
If you do not configure this policy, then Run Exit Node depends on what is selected in the Exit Node submenu.]]></string>
<string id="AdminPanel">Show the "Admin Panel" menu item</string>
<string id="AdminPanel_Help"><![CDATA[This policy can be used to show or hide the Admin Console item in the Tailscale Menu.
If you enable or don't configure this policy, the Admin Console item will be shown in the Tailscale menu when available.
@@ -162,55 +143,49 @@ If you enable or don't configure this policy, the Network Devices submenu will b
If you disable this policy, the Network Devices submenu will be hidden from the Tailscale menu. This does not affect other devices' visibility in the CLI.]]></string>
<string id="TestMenu">Show the "Debug" submenu</string>
<string id="TestMenu_Help"><![CDATA[This policy can be used to show or hide the Debug submenu of the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-debug-menu for more details.
If you enable or don't configure this policy, the Debug submenu will be shown in the Tailscale menu when opened while holding Ctrl.
If you disable this policy, the Debug submenu will be hidden from the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-debug-menu for more details.]]></string>
If you disable this policy, the Debug submenu will be hidden from the Tailscale menu.]]></string>
<string id="UpdateMenu">Show the "Update Available" menu item</string>
<string id="UpdateMenu_Help"><![CDATA[This policy can be used to show or hide the Update Available item in the Tailscale Menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-update-menu for more details.
If you enable or don't configure this policy, the Update Available item will be shown in the Tailscale menu when there is an update.
If you disable this policy, the Update Available item will be hidden from the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-update-menu for more details.]]></string>
If you disable this policy, the Update Available item will be hidden from the Tailscale menu.]]></string>
<string id="RunExitNode">Show the "Run Exit Node" menu item</string>
<string id="RunExitNode_Help"><![CDATA[This policy can be used to show or hide the Run Exit Node item in the Exit Node submenu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-run-as-exit-node-menu-item for more details.
This does not affect using the CLI to enable or disable advertising an exit node. If you wish to enable or disable this feature, see the Run Exit Node policy in the Settings category.
If you enable or don't configure this policy, the Run Exit Node item will be shown in the Exit Node submenu.
If you disable this policy, the Run Exit Node item will be hidden from the Exit Node submenu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-run-as-exit-node-menu-item for more details.]]></string>
If you disable this policy, the Run Exit Node item will be hidden from the Exit Node submenu.]]></string>
<string id="PreferencesMenu">Show the "Preferences" submenu</string>
<string id="PreferencesMenu_Help"><![CDATA[This policy can be used to show or hide the Preferences submenu of the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-preferences-menu for more details.
This does not affect using the CLI to modify that menu's preferences. If you wish to control those, look at the policies in the Settings category.
If you enable or don't configure this policy, the Preferences submenu will be shown in the Tailscale menu.
If you disable this policy, the Preferences submenu will be hidden from the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-preferences-menu for more details.]]></string>
If you disable this policy, the Preferences submenu will be hidden from the Tailscale menu.]]></string>
<string id="ExitNodesPicker">Show the "Exit Node" submenu</string>
<string id="ExitNodesPicker_Help"><![CDATA[This policy can be used to show or hide the Exit Node submenu of the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-exit-node-picker for more details.
This does not affect using the CLI to select or stop using an exit node. If you wish to control exit node usage, look at the "Require using a specific Exit Node" policy in the Settings category.
If you enable or don't configure this policy, the Exit Node submenu will be shown in the Tailscale menu.
If you disable this policy, the Exit Node submenu will be hidden from the Tailscale menu.
See https://tailscale.com/kb/1315/mdm-keys#hide-the-exit-node-picker for more details.]]></string>
If you disable this policy, the Exit Node submenu will be hidden from the Tailscale menu.]]></string>
<string id="KeyExpirationNotice">Specify a custom key expiration notification time</string>
<string id="KeyExpirationNotice_Help"><![CDATA[This policy can be used to configure how soon the notification appears before key expiry.
See https://tailscale.com/kb/1315/mdm-keys#set-the-key-expiration-notice-period for more details.
If you enable this policy and supply a valid time interval, the key expiry notification will begin to display when the current key has less than that amount of time remaining.
If you disable or don't configure this policy, the default time period will be used (as of Tailscale 1.56, this is 24 hours).
See https://tailscale.com/kb/1315/mdm-keys#set-the-key-expiration-notice-period for more details.]]></string>
If you disable or don't configure this policy, the default time period will be used (as of Tailscale 1.56, this is 24 hours).]]></string>
<string id="LogSCMInteractions">Log extra details about service events</string>
<string id="LogSCMInteractions_Help"><![CDATA[This policy can be used to enable additional logging related to Service Control Manager for debugging purposes.
This should only be enabled if recommended by Tailscale Support.
@@ -227,14 +202,13 @@ If you enable this policy, the DNS cache will be flushed on session unlock in ad
If you disable or don't configure this policy, the DNS cache is managed normally.]]></string>
<string id="PostureChecking">Collect data for posture checking</string>
<string id="PostureChecking_Help"><![CDATA[This policy can be used to require that the Posture Checking setting is configured a certain way.
See https://tailscale.com/kb/1315/mdm-keys#enable-gathering-device-posture-data and https://tailscale.com/kb/1326/device-identity for more details.
If you enable this policy, then data collection is always enabled.
If you disable this policy, then data collection is always disabled.
If you do not configure this policy, then data collection depends on if it has been enabled from the CLI (as of Tailscale 1.56), it may be present in the GUI in later versions.
See https://tailscale.com/kb/1315/mdm-keys#enable-gathering-device-posture-data and https://tailscale.com/kb/1326/device-identity for more details.]]></string>
If you do not configure this policy, then data collection depends on if it has been enabled from the CLI (as of Tailscale 1.56), it may be present in the GUI in later versions.]]></string>
<string id="ManagedBy">Show the "Managed By {Organization}" menu item</string>
<string id="ManagedBy_Help"><![CDATA[Use this policy to configure the “Managed By {Organization}” item in the Tailscale Menu.
@@ -270,9 +244,6 @@ See https://tailscale.com/kb/1315/mdm-keys#set-your-organization-name for more d
<label>Exit Node:</label>
</textBox>
</presentation>
<presentation id="AllowedSuggestedExitNodes">
<listBox refId="AllowedSuggestedExitNodesList">Target IDs:</listBox>
</presentation>
<presentation id="ManagedBy">
<textBox refId="ManagedByOrganization">
<label>Organization Name:</label>

View File

@@ -50,10 +50,6 @@
displayName="$(string.SINCE_V1_74)">
<and><reference ref="TAILSCALE_PRODUCT"/></and>
</definition>
<definition name="SINCE_V1_78"
displayName="$(string.SINCE_V1_78)">
<and><reference ref="TAILSCALE_PRODUCT"/></and>
</definition>
</definitions>
</supportedOn>
<categories>
@@ -98,14 +94,7 @@
<parentCategory ref="Settings_Category" />
<supportedOn ref="SINCE_V1_56" />
<elements>
<text id="ExitNodeIDPrompt" valueName="ExitNodeID" required="true" />>
</elements>
</policy>
<policy name="AllowedSuggestedExitNodes" class="Machine" displayName="$(string.AllowedSuggestedExitNodes)" explainText="$(string.AllowedSuggestedExitNodes_Help)" presentation="$(presentation.AllowedSuggestedExitNodes)" key="Software\Policies\Tailscale\AllowedSuggestedExitNodes">
<parentCategory ref="Settings_Category" />
<supportedOn ref="SINCE_V1_78" />
<elements>
<list id="AllowedSuggestedExitNodesList" />
<text id="ExitNodeIDPrompt" valueName="ExitNodeID" required="true" />
</elements>
</policy>
<policy name="AllowIncomingConnections" class="Machine" displayName="$(string.AllowIncomingConnections)" explainText="$(string.AllowIncomingConnections_Help)" key="Software\Policies\Tailscale" valueName="AllowIncomingConnections">
@@ -208,7 +197,7 @@
<decimal value="0" />
</disabledValue>
</policy>
<policy name="AdminConsole" class="Both" displayName="$(string.AdminConsole)" explainText="$(string.AdminConsole_Help)" key="Software\Policies\Tailscale" valueName="AdminConsole">
<policy name="AdminPanel" class="Machine" displayName="$(string.AdminPanel)" explainText="$(string.AdminPanel_Help)" key="Software\Policies\Tailscale" valueName="AdminPanel">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -218,7 +207,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="NetworkDevices" class="Both" displayName="$(string.NetworkDevices)" explainText="$(string.NetworkDevices_Help)" key="Software\Policies\Tailscale" valueName="NetworkDevices">
<policy name="NetworkDevices" class="Machine" displayName="$(string.NetworkDevices)" explainText="$(string.NetworkDevices_Help)" key="Software\Policies\Tailscale" valueName="NetworkDevices">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -228,7 +217,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="TestMenu" class="Both" displayName="$(string.TestMenu)" explainText="$(string.TestMenu_Help)" key="Software\Policies\Tailscale" valueName="TestMenu">
<policy name="TestMenu" class="Machine" displayName="$(string.TestMenu)" explainText="$(string.TestMenu_Help)" key="Software\Policies\Tailscale" valueName="TestMenu">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -238,7 +227,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="UpdateMenu" class="Both" displayName="$(string.UpdateMenu)" explainText="$(string.UpdateMenu_Help)" key="Software\Policies\Tailscale" valueName="UpdateMenu">
<policy name="UpdateMenu" class="Machine" displayName="$(string.UpdateMenu)" explainText="$(string.UpdateMenu_Help)" key="Software\Policies\Tailscale" valueName="UpdateMenu">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -248,7 +237,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="RunExitNode" class="Both" displayName="$(string.RunExitNode)" explainText="$(string.RunExitNode_Help)" key="Software\Policies\Tailscale" valueName="RunExitNode">
<policy name="RunExitNode" class="Machine" displayName="$(string.RunExitNode)" explainText="$(string.RunExitNode_Help)" key="Software\Policies\Tailscale" valueName="RunExitNode">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -258,7 +247,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="PreferencesMenu" class="Both" displayName="$(string.PreferencesMenu)" explainText="$(string.PreferencesMenu_Help)" key="Software\Policies\Tailscale" valueName="PreferencesMenu">
<policy name="PreferencesMenu" class="Machine" displayName="$(string.PreferencesMenu)" explainText="$(string.PreferencesMenu_Help)" key="Software\Policies\Tailscale" valueName="PreferencesMenu">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -268,7 +257,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="ExitNodesPicker" class="Both" displayName="$(string.ExitNodesPicker)" explainText="$(string.ExitNodesPicker_Help)" key="Software\Policies\Tailscale" valueName="ExitNodesPicker">
<policy name="ExitNodesPicker" class="Machine" displayName="$(string.ExitNodesPicker)" explainText="$(string.ExitNodesPicker_Help)" key="Software\Policies\Tailscale" valueName="ExitNodesPicker">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_22" />
<enabledValue>
@@ -278,7 +267,7 @@
<string>hide</string>
</disabledValue>
</policy>
<policy name="ManagedBy" class="Both" displayName="$(string.ManagedBy)" explainText="$(string.ManagedBy_Help)" presentation="$(presentation.ManagedBy)" key="Software\Policies\Tailscale">
<policy name="ManagedBy" class="Machine" displayName="$(string.ManagedBy)" explainText="$(string.ManagedBy_Help)" presentation="$(presentation.ManagedBy)" key="Software\Policies\Tailscale">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_62" />
<elements>
@@ -287,7 +276,7 @@
<text id="ManagedBySupportURL" valueName="ManagedByURL" />
</elements>
</policy>
<policy name="KeyExpirationNotice" class="Both" displayName="$(string.KeyExpirationNotice)" explainText="$(string.KeyExpirationNotice_Help)" presentation="$(presentation.KeyExpirationNotice)" key="Software\Policies\Tailscale">
<policy name="KeyExpirationNotice" class="Machine" displayName="$(string.KeyExpirationNotice)" explainText="$(string.KeyExpirationNotice_Help)" presentation="$(presentation.KeyExpirationNotice)" key="Software\Policies\Tailscale">
<parentCategory ref="UI_Category" />
<supportedOn ref="SINCE_V1_50" />
<elements>

2
go.mod
View File

@@ -85,7 +85,7 @@ require (
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e
github.com/tc-hib/winres v0.2.1
github.com/tcnksm/go-httpstat v0.2.0

4
go.sum
View File

@@ -941,8 +941,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:t
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ=
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc h1:cezaQN9pvKVaw56Ma5qr/G646uKIYP0yQf+OyWN/okc=
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=

View File

@@ -1 +1 @@
96578f73d04e1a231fa2a495ad3fa97747785bc6
bf15628b759344c6fc7763795a405ba65b8be5d7

View File

@@ -73,8 +73,6 @@ const (
NotifyInitialOutgoingFiles // if set, the first Notify message (sent immediately) will contain the current Taildrop OutgoingFiles
NotifyInitialHealthState // if set, the first Notify message (sent immediately) will contain the current health.State of the client
NotifyRateLimit // if set, rate limit spammy netmap updates to every few seconds
)
// Notify is a communication from a backend (e.g. tailscaled) to a frontend
@@ -102,6 +100,7 @@ type Notify struct {
NetMap *netmap.NetworkMap // if non-nil, the new or current netmap
Engine *EngineStatus // if non-nil, the new or current wireguard stats
BrowseToURL *string // if non-nil, UI should open a browser right now
BackendLogID *string // if non-nil, the public logtail ID used by backend
// FilesWaiting if non-nil means that files are buffered in
// the Tailscale daemon and ready for local transfer to the
@@ -174,6 +173,9 @@ func (n Notify) String() string {
if n.BrowseToURL != nil {
sb.WriteString("URL=<...> ")
}
if n.BackendLogID != nil {
sb.WriteString("BackendLogID ")
}
if n.FilesWaiting != nil {
sb.WriteString("FilesWaiting ")
}

View File

@@ -1,160 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"time"
"tailscale.com/ipn"
"tailscale.com/tstime"
)
type rateLimitingBusSender struct {
fn func(*ipn.Notify) (keepGoing bool)
lastFlush time.Time // last call to fn, or zero value if none
interval time.Duration // 0 to flush immediately; non-zero to rate limit sends
clock tstime.DefaultClock // non-nil for testing
didSendTestHook func() // non-nil for testing
// pending, if non-nil, is the pending notification that we
// haven't sent yet. We own this memory to mutate.
pending *ipn.Notify
// flushTimer is non-nil if the timer is armed.
flushTimer tstime.TimerController // effectively a *time.Timer
flushTimerC <-chan time.Time // ... said ~Timer's C chan
}
func (s *rateLimitingBusSender) close() {
if s.flushTimer != nil {
s.flushTimer.Stop()
}
}
func (s *rateLimitingBusSender) flushChan() <-chan time.Time {
return s.flushTimerC
}
func (s *rateLimitingBusSender) flush() (keepGoing bool) {
if n := s.pending; n != nil {
s.pending = nil
return s.flushNotify(n)
}
return true
}
func (s *rateLimitingBusSender) flushNotify(n *ipn.Notify) (keepGoing bool) {
s.lastFlush = s.clock.Now()
return s.fn(n)
}
// send conditionally sends n to the underlying fn, possibly rate
// limiting it, depending on whether s.interval is set, and whether
// n is a notable notification that the client (typically a GUI) would
// want to act on (render) immediately.
//
// It returns whether the caller should keep looping.
//
// The passed-in memory 'n' is owned by the caller and should
// not be mutated.
func (s *rateLimitingBusSender) send(n *ipn.Notify) (keepGoing bool) {
if s.interval <= 0 {
// No rate limiting case.
return s.fn(n)
}
if isNotableNotify(n) {
// Notable notifications are always sent immediately.
// But first send any boring one that was pending.
// TODO(bradfitz): there might be a boring one pending
// with a NetMap or Engine field that is redundant
// with the new one (n) with NetMap or Engine populated.
// We should clear the pending one's NetMap/Engine in
// that case. Or really, merge the two, but mergeBoringNotifies
// only handles the case of both sides being boring.
// So for now, flush both.
if !s.flush() {
return false
}
return s.flushNotify(n)
}
s.pending = mergeBoringNotifies(s.pending, n)
d := s.clock.Now().Sub(s.lastFlush)
if d > s.interval {
return s.flush()
}
nextFlushIn := s.interval - d
if s.flushTimer == nil {
s.flushTimer, s.flushTimerC = s.clock.NewTimer(nextFlushIn)
} else {
s.flushTimer.Reset(nextFlushIn)
}
return true
}
func (s *rateLimitingBusSender) Run(ctx context.Context, ch <-chan *ipn.Notify) {
for {
select {
case <-ctx.Done():
return
case n, ok := <-ch:
if !ok {
return
}
if !s.send(n) {
return
}
if f := s.didSendTestHook; f != nil {
f()
}
case <-s.flushChan():
if !s.flush() {
return
}
}
}
}
// mergeBoringNotify merges new notify 'src' into possibly-nil 'dst',
// either mutating 'dst' or allocating a new one if 'dst' is nil,
// returning the merged result.
//
// dst and src must both be "boring" (i.e. not notable per isNotifiableNotify).
func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
if dst == nil {
dst = &ipn.Notify{Version: src.Version}
}
if src.NetMap != nil {
dst.NetMap = src.NetMap
}
if src.Engine != nil {
dst.Engine = src.Engine
}
return dst
}
// isNotableNotify reports whether n is a "notable" notification that
// should be sent on the IPN bus immediately (e.g. to GUIs) without
// rate limiting it for a few seconds.
//
// It effectively reports whether n contains any field set that's
// not NetMap or Engine.
func isNotableNotify(n *ipn.Notify) bool {
if n == nil {
return false
}
return n.State != nil ||
n.SessionID != "" ||
n.BrowseToURL != nil ||
n.LocalTCPPort != nil ||
n.ClientVersion != nil ||
n.Prefs != nil ||
n.ErrMessage != nil ||
n.LoginFinished != nil ||
!n.DriveShares.IsNil() ||
n.Health != nil ||
len(n.IncomingFiles) > 0 ||
len(n.OutgoingFiles) > 0 ||
n.FilesWaiting != nil
}

View File

@@ -1,220 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"reflect"
"slices"
"testing"
"time"
"tailscale.com/drive"
"tailscale.com/ipn"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
)
func TestIsNotableNotify(t *testing.T) {
tests := []struct {
name string
notify *ipn.Notify
want bool
}{
{"nil", nil, false},
{"empty", &ipn.Notify{}, false},
{"version", &ipn.Notify{Version: "foo"}, false},
{"netmap", &ipn.Notify{NetMap: new(netmap.NetworkMap)}, false},
{"engine", &ipn.Notify{Engine: new(ipn.EngineStatus)}, false},
}
// Then for all other fields, assume they're notable.
// We use reflect to catch fields that might be added in the future without
// remembering to update the [isNotableNotify] function.
rt := reflect.TypeFor[ipn.Notify]()
for i := range rt.NumField() {
n := &ipn.Notify{}
sf := rt.Field(i)
switch sf.Name {
case "_", "NetMap", "Engine", "Version":
// Already covered above or not applicable.
continue
case "DriveShares":
n.DriveShares = views.SliceOfViews[*drive.Share, drive.ShareView](make([]*drive.Share, 1))
default:
rf := reflect.ValueOf(n).Elem().Field(i)
switch rf.Kind() {
case reflect.Pointer:
rf.Set(reflect.New(rf.Type().Elem()))
case reflect.String:
rf.SetString("foo")
case reflect.Slice:
rf.Set(reflect.MakeSlice(rf.Type(), 1, 1))
default:
t.Errorf("unhandled field kind %v for %q", rf.Kind(), sf.Name)
}
}
tests = append(tests, struct {
name string
notify *ipn.Notify
want bool
}{
name: "field-" + rt.Field(i).Name,
notify: n,
want: true,
})
}
for _, tt := range tests {
if got := isNotableNotify(tt.notify); got != tt.want {
t.Errorf("%v: got %v; want %v", tt.name, got, tt.want)
}
}
}
type rateLimitingBusSenderTester struct {
tb testing.TB
got []*ipn.Notify
clock *tstest.Clock
s *rateLimitingBusSender
}
func (st *rateLimitingBusSenderTester) init() {
if st.s != nil {
return
}
st.clock = tstest.NewClock(tstest.ClockOpts{
Start: time.Unix(1731777537, 0), // time I wrote this test :)
})
st.s = &rateLimitingBusSender{
clock: tstime.DefaultClock{Clock: st.clock},
fn: func(n *ipn.Notify) bool {
st.got = append(st.got, n)
return true
},
}
}
func (st *rateLimitingBusSenderTester) send(n *ipn.Notify) {
st.tb.Helper()
st.init()
if !st.s.send(n) {
st.tb.Fatal("unexpected send failed")
}
}
func (st *rateLimitingBusSenderTester) advance(d time.Duration) {
st.tb.Helper()
st.clock.Advance(d)
select {
case <-st.s.flushChan():
if !st.s.flush() {
st.tb.Fatal("unexpected flush failed")
}
default:
}
}
func TestRateLimitingBusSender(t *testing.T) {
nm1 := &ipn.Notify{NetMap: new(netmap.NetworkMap)}
nm2 := &ipn.Notify{NetMap: new(netmap.NetworkMap)}
eng1 := &ipn.Notify{Engine: new(ipn.EngineStatus)}
eng2 := &ipn.Notify{Engine: new(ipn.EngineStatus)}
t.Run("unbuffered", func(t *testing.T) {
st := &rateLimitingBusSenderTester{tb: t}
st.send(nm1)
st.send(nm2)
st.send(eng1)
st.send(eng2)
if !slices.Equal(st.got, []*ipn.Notify{nm1, nm2, eng1, eng2}) {
t.Errorf("got %d items; want 4 specific ones, unmodified", len(st.got))
}
})
t.Run("buffered", func(t *testing.T) {
st := &rateLimitingBusSenderTester{tb: t}
st.init()
st.s.interval = 1 * time.Second
st.send(&ipn.Notify{Version: "initial"})
if len(st.got) != 1 {
t.Fatalf("got %d items; expected 1 (first to flush immediately)", len(st.got))
}
st.send(nm1)
st.send(nm2)
st.send(eng1)
st.send(eng2)
if len(st.got) != 1 {
if len(st.got) != 1 {
t.Fatalf("got %d items; expected still just that first 1", len(st.got))
}
}
// But moving the clock should flush the rest, collasced into one new one.
st.advance(5 * time.Second)
if len(st.got) != 2 {
t.Fatalf("got %d items; want 2", len(st.got))
}
gotn := st.got[1]
if gotn.NetMap != nm2.NetMap {
t.Errorf("got wrong NetMap; got %p", gotn.NetMap)
}
if gotn.Engine != eng2.Engine {
t.Errorf("got wrong Engine; got %p", gotn.Engine)
}
if t.Failed() {
t.Logf("failed Notify was: %v", logger.AsJSON(gotn))
}
})
// Test the Run method
t.Run("run", func(t *testing.T) {
st := &rateLimitingBusSenderTester{tb: t}
st.init()
st.s.interval = 1 * time.Second
st.s.lastFlush = st.clock.Now() // pretend we just flushed
flushc := make(chan *ipn.Notify, 1)
st.s.fn = func(n *ipn.Notify) bool {
flushc <- n
return true
}
didSend := make(chan bool, 2)
st.s.didSendTestHook = func() { didSend <- true }
waitSend := func() {
select {
case <-didSend:
case <-time.After(5 * time.Second):
t.Error("timeout waiting for call to send")
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
incoming := make(chan *ipn.Notify, 2)
go func() {
incoming <- nm1
waitSend()
incoming <- nm2
waitSend()
st.advance(5 * time.Second)
select {
case n := <-flushc:
if n.NetMap != nm2.NetMap {
t.Errorf("got wrong NetMap; got %p", n.NetMap)
}
case <-time.After(10 * time.Second):
t.Error("timeout")
}
cancel()
}()
st.s.Run(ctx, incoming)
})
}

View File

@@ -77,9 +77,6 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
// Linux netfilter.
req("POST /netfilter-kind"): handleC2NSetNetfilterKind,
// VIP services.
req("GET /vip-services"): handleC2NVIPServicesGet,
}
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
@@ -272,12 +269,6 @@ func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNoContent)
}
func handleC2NVIPServicesGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /vip-services received")
json.NewEncoder(w).Encode(b.VIPServices())
}
func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received")

View File

@@ -9,7 +9,6 @@ import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
@@ -87,6 +86,7 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/types/empty"
"tailscale.com/types/key"
"tailscale.com/types/lazy"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/netmap"
@@ -105,7 +105,6 @@ import (
"tailscale.com/util/rands"
"tailscale.com/util/set"
"tailscale.com/util/syspolicy"
"tailscale.com/util/syspolicy/rsop"
"tailscale.com/util/systemd"
"tailscale.com/util/testenv"
"tailscale.com/util/uniq"
@@ -178,28 +177,27 @@ type watchSession struct {
// state machine generates events back out to zero or more components.
type LocalBackend struct {
// Elements that are thread-safe or constant after construction.
ctx context.Context // canceled by Close
ctxCancel context.CancelFunc // cancels ctx
logf logger.Logf // general logging
keyLogf logger.Logf // for printing list of peers on change
statsLogf logger.Logf // for printing peers stats on change
sys *tsd.System
health *health.Tracker // always non-nil
metrics metrics
e wgengine.Engine // non-nil; TODO(bradfitz): remove; use sys
store ipn.StateStore // non-nil; TODO(bradfitz): remove; use sys
dialer *tsdial.Dialer // non-nil; TODO(bradfitz): remove; use sys
pushDeviceToken syncs.AtomicValue[string]
backendLogID logid.PublicID
unregisterNetMon func()
unregisterHealthWatch func()
unregisterSysPolicyWatch func()
portpoll *portlist.Poller // may be nil
portpollOnce sync.Once // guards starting readPoller
varRoot string // or empty if SetVarRoot never called
logFlushFunc func() // or nil if SetLogFlusher wasn't called
em *expiryManager // non-nil
sshAtomicBool atomic.Bool
ctx context.Context // canceled by Close
ctxCancel context.CancelFunc // cancels ctx
logf logger.Logf // general logging
keyLogf logger.Logf // for printing list of peers on change
statsLogf logger.Logf // for printing peers stats on change
sys *tsd.System
health *health.Tracker // always non-nil
metrics metrics
e wgengine.Engine // non-nil; TODO(bradfitz): remove; use sys
store ipn.StateStore // non-nil; TODO(bradfitz): remove; use sys
dialer *tsdial.Dialer // non-nil; TODO(bradfitz): remove; use sys
pushDeviceToken syncs.AtomicValue[string]
backendLogID logid.PublicID
unregisterNetMon func()
unregisterHealthWatch func()
portpoll *portlist.Poller // may be nil
portpollOnce sync.Once // guards starting readPoller
varRoot string // or empty if SetVarRoot never called
logFlushFunc func() // or nil if SetLogFlusher wasn't called
em *expiryManager // non-nil
sshAtomicBool atomic.Bool
// webClientAtomicBool controls whether the web client is running. This should
// be true unless the disable-web-client node attribute has been set.
webClientAtomicBool atomic.Bool
@@ -355,12 +353,6 @@ type LocalBackend struct {
// avoid unnecessary churn between multiple equally-good options.
lastSuggestedExitNode tailcfg.StableNodeID
// allowedSuggestedExitNodes is a set of exit nodes permitted by the most recent
// [syspolicy.AllowedSuggestedExitNodes] value. The allowedSuggestedExitNodesMu
// mutex guards access to this set.
allowedSuggestedExitNodesMu sync.Mutex
allowedSuggestedExitNodes set.Set[tailcfg.StableNodeID]
// refreshAutoExitNode indicates if the exit node should be recomputed when the next netcheck report is available.
refreshAutoExitNode bool
@@ -417,7 +409,7 @@ type clientGen func(controlclient.Options) (controlclient.Client, error)
// but is not actually running.
//
// If dialer is nil, a new one is made.
func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, loginFlags controlclient.LoginFlags) (_ *LocalBackend, err error) {
func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, loginFlags controlclient.LoginFlags) (*LocalBackend, error) {
e := sys.Engine.Get()
store := sys.StateStore.Get()
dialer := sys.Dialer.Get()
@@ -492,15 +484,6 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
}
}
if b.unregisterSysPolicyWatch, err = b.registerSysPolicyWatch(); err != nil {
return nil, err
}
defer func() {
if err != nil {
b.unregisterSysPolicyWatch()
}
}()
netMon := sys.NetMon.Get()
b.sockstatLogger, err = sockstatlog.NewLogger(logpolicy.LogsDir(logf), logf, logID, netMon, sys.HealthTracker())
if err != nil {
@@ -997,7 +980,6 @@ func (b *LocalBackend) Shutdown() {
b.unregisterNetMon()
b.unregisterHealthWatch()
b.unregisterSysPolicyWatch()
if cc != nil {
cc.Shutdown()
}
@@ -1506,10 +1488,10 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
b.logf("SetControlClientStatus failed to select auto exit node: %v", err)
}
}
if applySysPolicy(prefs, b.lastSuggestedExitNode) {
if setExitNodeID(prefs, curNetMap, b.lastSuggestedExitNode) {
prefsChanged = true
}
if setExitNodeID(prefs, curNetMap) {
if applySysPolicy(prefs) {
prefsChanged = true
}
@@ -1675,37 +1657,12 @@ var preferencePolicies = []preferencePolicyInfo{
// applySysPolicy overwrites configured preferences with policies that may be
// configured by the system administrator in an OS-specific way.
func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID) (anyChange bool) {
func applySysPolicy(prefs *ipn.Prefs) (anyChange bool) {
if controlURL, err := syspolicy.GetString(syspolicy.ControlURL, prefs.ControlURL); err == nil && prefs.ControlURL != controlURL {
prefs.ControlURL = controlURL
anyChange = true
}
if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" {
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
if shouldAutoExitNode() && lastSuggestedExitNode != "" {
exitNodeID = lastSuggestedExitNode
}
// Note: when exitNodeIDStr == "auto" && lastSuggestedExitNode == "",
// then exitNodeID is now "auto" which will never match a peer's node ID.
// When there is no a peer matching the node ID, traffic will blackhole,
// preventing accidental non-exit-node usage when a policy is in effect that requires an exit node.
if prefs.ExitNodeID != exitNodeID || prefs.ExitNodeIP.IsValid() {
anyChange = true
}
prefs.ExitNodeID = exitNodeID
prefs.ExitNodeIP = netip.Addr{}
} else if exitNodeIPStr, _ := syspolicy.GetString(syspolicy.ExitNodeIP, ""); exitNodeIPStr != "" {
exitNodeIP, err := netip.ParseAddr(exitNodeIPStr)
if exitNodeIP.IsValid() && err == nil {
if prefs.ExitNodeID != "" || prefs.ExitNodeIP != exitNodeIP {
anyChange = true
}
prefs.ExitNodeID = ""
prefs.ExitNodeIP = exitNodeIP
}
}
for _, opt := range preferencePolicies {
if po, err := syspolicy.GetPreferenceOption(opt.key); err == nil {
curVal := opt.get(prefs.View())
@@ -1720,54 +1677,6 @@ func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID
return anyChange
}
// registerSysPolicyWatch subscribes to syspolicy change notifications
// and immediately applies the effective syspolicy settings to the current profile.
func (b *LocalBackend) registerSysPolicyWatch() (unregister func(), err error) {
if unregister, err = syspolicy.RegisterChangeCallback(b.sysPolicyChanged); err != nil {
return nil, fmt.Errorf("syspolicy: LocalBacked failed to register policy change callback: %v", err)
}
if prefs, anyChange := b.applySysPolicy(); anyChange {
b.logf("syspolicy: changed initial profile prefs: %v", prefs.Pretty())
}
b.refreshAllowedSuggestions()
return unregister, nil
}
// applySysPolicy overwrites the current profile's preferences with policies
// that may be configured by the system administrator in an OS-specific way.
//
// b.mu must not be held.
func (b *LocalBackend) applySysPolicy() (_ ipn.PrefsView, anyChange bool) {
unlock := b.lockAndGetUnlock()
prefs := b.pm.CurrentPrefs().AsStruct()
if !applySysPolicy(prefs, b.lastSuggestedExitNode) {
unlock.UnlockEarly()
return prefs.View(), false
}
return b.setPrefsLockedOnEntry(prefs, unlock), true
}
// sysPolicyChanged is a callback triggered by syspolicy when it detects
// a change in one or more syspolicy settings.
func (b *LocalBackend) sysPolicyChanged(policy *rsop.PolicyChange) {
if policy.HasChanged(syspolicy.AllowedSuggestedExitNodes) {
b.refreshAllowedSuggestions()
// Re-evaluate exit node suggestion now that the policy setting has changed.
b.mu.Lock()
_, err := b.suggestExitNodeLocked(nil)
b.mu.Unlock()
if err != nil && !errors.Is(err, ErrNoPreferredDERP) {
b.logf("failed to select auto exit node: %v", err)
}
// If [syspolicy.ExitNodeID] is set to `auto:any`, the suggested exit node ID
// will be used when [applySysPolicy] updates the current profile's prefs.
}
if prefs, anyChange := b.applySysPolicy(); anyChange {
b.logf("syspolicy: changed profile prefs: %v", prefs.Pretty())
}
}
var _ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil)
// UpdateNetmapDelta implements controlclient.NetmapDeltaUpdater.
@@ -1860,7 +1769,30 @@ func (b *LocalBackend) updateNetmapDeltaLocked(muts []netmap.NodeMutation) (hand
// setExitNodeID updates prefs to reference an exit node by ID, rather
// than by IP. It returns whether prefs was mutated.
func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) {
func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap, lastSuggestedExitNode tailcfg.StableNodeID) (prefsChanged bool) {
if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" {
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
if shouldAutoExitNode() && lastSuggestedExitNode != "" {
exitNodeID = lastSuggestedExitNode
}
// Note: when exitNodeIDStr == "auto" && lastSuggestedExitNode == "", then exitNodeID is now "auto" which will never match a peer's node ID.
// When there is no a peer matching the node ID, traffic will blackhole, preventing accidental non-exit-node usage when a policy is in effect that requires an exit node.
changed := prefs.ExitNodeID != exitNodeID || prefs.ExitNodeIP.IsValid()
prefs.ExitNodeID = exitNodeID
prefs.ExitNodeIP = netip.Addr{}
return changed
}
oldExitNodeID := prefs.ExitNodeID
if exitNodeIPStr, _ := syspolicy.GetString(syspolicy.ExitNodeIP, ""); exitNodeIPStr != "" {
exitNodeIP, err := netip.ParseAddr(exitNodeIPStr)
if exitNodeIP.IsValid() && err == nil {
prefsChanged = prefs.ExitNodeID != "" || prefs.ExitNodeIP != exitNodeIP
prefs.ExitNodeID = ""
prefs.ExitNodeIP = exitNodeIP
}
}
if nm == nil {
// No netmap, can't resolve anything.
return false
@@ -1878,7 +1810,6 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool)
prefsChanged = true
}
oldExitNodeID := prefs.ExitNodeID
for _, peer := range nm.Peers {
for _, addr := range peer.Addresses().All() {
if !addr.IsSingleIP() || addr.Addr() != prefs.ExitNodeIP {
@@ -1888,7 +1819,7 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool)
// reference it directly for next time.
prefs.ExitNodeID = peer.StableID()
prefs.ExitNodeIP = netip.Addr{}
return prefsChanged || oldExitNodeID != prefs.ExitNodeID
return oldExitNodeID != prefs.ExitNodeID
}
}
@@ -2064,6 +1995,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
defer unlock()
if opts.UpdatePrefs != nil {
log.Printf("TESTPREFS: update prefs non-nil")
if err := b.checkPrefsLocked(opts.UpdatePrefs); err != nil {
return err
}
@@ -2130,6 +2062,10 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
}
prefs := b.pm.CurrentPrefs()
log.Printf("TESTPREFS persistent prefs: %v", prefs.Persist())
if s := prefs.Persist().AsStruct(); s != nil {
log.Printf("TESTPREFS persistent prefs private key is %v", s.PrivateNodeKey)
}
wantRunning := prefs.WantRunning()
if wantRunning {
if err := b.initMachineKeyLocked(); err != nil {
@@ -2225,7 +2161,10 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
blid := b.backendLogID.String()
b.logf("Backend: logs: be:%v fe:%v", blid, opts.FrontendLogID)
b.sendToLocked(ipn.Notify{Prefs: &prefs}, allClients)
b.sendToLocked(ipn.Notify{
BackendLogID: &blid,
Prefs: &prefs,
}, allClients)
if !loggedOut && (b.hasNodeKeyLocked() || confWantRunning) {
// If we know that we're either logged in or meant to be
@@ -2848,17 +2787,20 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
go b.pollRequestEngineStatus(ctx)
}
// TODO(marwan-at-work): check err
// TODO(marwan-at-work): streaming background logs?
defer b.DeleteForegroundSession(sessionID)
sender := &rateLimitingBusSender{fn: fn}
defer sender.close()
if mask&ipn.NotifyRateLimit != 0 {
sender.interval = 3 * time.Second
for {
select {
case <-ctx.Done():
return
case n := <-ch:
if !fn(n) {
return
}
}
}
sender.Run(ctx, ch)
}
// pollRequestEngineStatus calls b.e.RequestStatus every 2 seconds until ctx
@@ -3740,16 +3682,11 @@ func updateExitNodeUsageWarning(p ipn.PrefsView, state *netmon.State, healthTrac
}
func (b *LocalBackend) checkExitNodePrefsLocked(p *ipn.Prefs) error {
tryingToUseExitNode := p.ExitNodeIP.IsValid() || p.ExitNodeID != ""
if !tryingToUseExitNode {
return nil
}
if err := featureknob.CanUseExitNode(); err != nil {
return err
}
if p.AdvertisesExitNode() {
if (p.ExitNodeIP.IsValid() || p.ExitNodeID != "") && p.AdvertisesExitNode() {
return errors.New("Cannot advertise an exit node and use an exit node at the same time.")
}
return nil
@@ -3917,12 +3854,12 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
if oldp.Valid() {
newp.Persist = oldp.Persist().AsStruct() // caller isn't allowed to override this
}
// applySysPolicyToPrefsLocked returns whether it updated newp,
// but everything in this function treats b.prefs as completely new
// anyway, so its return value can be ignored here.
applySysPolicy(newp, b.lastSuggestedExitNode)
// setExitNodeID does likewise. No-op if no exit node resolution is needed.
setExitNodeID(newp, netMap)
// setExitNodeID returns whether it updated b.prefs, but
// everything in this function treats b.prefs as completely new
// anyway. No-op if no exit node resolution is needed.
setExitNodeID(newp, netMap, b.lastSuggestedExitNode)
// applySysPolicy does likewise so we can also ignore its return value.
applySysPolicy(newp)
// We do this to avoid holding the lock while doing everything else.
oldHi := b.hostinfo
@@ -3959,14 +3896,10 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
}
prefs := newp.View()
np := b.pm.CurrentProfile().NetworkProfile
if netMap != nil {
np = ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}
}
if err := b.pm.SetPrefs(prefs, np); err != nil {
if err := b.pm.SetPrefs(prefs, ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
b.logf("failed to save new controlclient state: %v", err)
}
@@ -4960,14 +4893,6 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
}
hi.SSH_HostKeys = sshHostKeys
services := vipServicesFromPrefs(prefs)
if len(services) > 0 {
buf, _ := json.Marshal(services)
hi.ServicesHash = fmt.Sprintf("%02x", sha256.Sum256(buf))
} else {
hi.ServicesHash = ""
}
// The Hostinfo.WantIngress field tells control whether this node wants to
// be wired up for ingress connections. If harmless if it's accidentally
// true; the actual policy is controlled in tailscaled by ServeConfig. But
@@ -6858,6 +6783,7 @@ func (b *LocalBackend) CurrentProfile() ipn.LoginProfile {
// NewProfile creates and switches to the new profile.
func (b *LocalBackend) NewProfile() error {
log.Printf("TESTPREFS: NewProfile LB")
unlock := b.lockAndGetUnlock()
defer unlock()
@@ -7221,7 +7147,7 @@ func (b *LocalBackend) suggestExitNodeLocked(netMap *netmap.NetworkMap) (respons
lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx)
prevSuggestion := b.lastSuggestedExitNode
res, err := suggestExitNode(lastReport, netMap, prevSuggestion, randomRegion, randomNode, b.getAllowedSuggestions())
res, err := suggestExitNode(lastReport, netMap, prevSuggestion, randomRegion, randomNode, getAllowedSuggestions())
if err != nil {
return res, err
}
@@ -7235,22 +7161,6 @@ func (b *LocalBackend) SuggestExitNode() (response apitype.ExitNodeSuggestionRes
return b.suggestExitNodeLocked(nil)
}
// getAllowedSuggestions returns a set of exit nodes permitted by the most recent
// [syspolicy.AllowedSuggestedExitNodes] value. Callers must not mutate the returned set.
func (b *LocalBackend) getAllowedSuggestions() set.Set[tailcfg.StableNodeID] {
b.allowedSuggestedExitNodesMu.Lock()
defer b.allowedSuggestedExitNodesMu.Unlock()
return b.allowedSuggestedExitNodes
}
// refreshAllowedSuggestions rebuilds the set of permitted exit nodes
// from the current [syspolicy.AllowedSuggestedExitNodes] value.
func (b *LocalBackend) refreshAllowedSuggestions() {
b.allowedSuggestedExitNodesMu.Lock()
defer b.allowedSuggestedExitNodesMu.Unlock()
b.allowedSuggestedExitNodes = fillAllowedSuggestions()
}
// selectRegionFunc returns a DERP region from the slice of candidate regions.
// The value is returned, not the slice index.
type selectRegionFunc func(views.Slice[int]) int
@@ -7260,6 +7170,8 @@ type selectRegionFunc func(views.Slice[int]) int
// choice.
type selectNodeFunc func(nodes views.Slice[tailcfg.NodeView], last tailcfg.StableNodeID) tailcfg.NodeView
var getAllowedSuggestions = lazy.SyncFunc(fillAllowedSuggestions)
func fillAllowedSuggestions() set.Set[tailcfg.StableNodeID] {
nodes, err := syspolicy.GetStringArray(syspolicy.AllowedSuggestedExitNodes, nil)
if err != nil {
@@ -7579,42 +7491,3 @@ func maybeUsernameOf(actor ipnauth.Actor) string {
}
return username
}
// VIPServices returns the list of tailnet services that this node
// is serving as a destination for.
// The returned memory is owned by the caller.
func (b *LocalBackend) VIPServices() []*tailcfg.VIPService {
b.mu.Lock()
defer b.mu.Unlock()
return vipServicesFromPrefs(b.pm.CurrentPrefs())
}
func vipServicesFromPrefs(prefs ipn.PrefsView) []*tailcfg.VIPService {
// keyed by service name
var services map[string]*tailcfg.VIPService
// TODO(naman): this envknob will be replaced with service-specific port
// information once we start storing that.
var allPortsServices []string
if env := envknob.String("TS_DEBUG_ALLPORTS_SERVICES"); env != "" {
allPortsServices = strings.Split(env, ",")
}
for _, s := range allPortsServices {
mak.Set(&services, s, &tailcfg.VIPService{
Name: s,
Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
})
}
for _, s := range prefs.AdvertiseServices().AsSlice() {
if services == nil || services[s] == nil {
mak.Set(&services, s, &tailcfg.VIPService{
Name: s,
})
}
services[s].Active = true
}
return slices.Collect(maps.Values(services))
}

View File

@@ -30,7 +30,6 @@ import (
"tailscale.com/control/controlclient"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
@@ -458,7 +457,6 @@ func newTestLocalBackendWithSys(t testing.TB, sys *tsd.System) *LocalBackend {
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(lb.Shutdown)
return lb
}
@@ -1789,13 +1787,10 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
b := newTestBackend(t)
policyStore := source.NewTestStore(t)
if test.exitNodeIDKey {
policyStore.SetStrings(source.TestSettingOf(syspolicy.ExitNodeID, test.exitNodeID))
}
if test.exitNodeIPKey {
policyStore.SetStrings(source.TestSettingOf(syspolicy.ExitNodeIP, test.exitNodeIP))
}
policyStore := source.NewTestStoreOf(t,
source.TestSettingOf(syspolicy.ExitNodeID, test.exitNodeID),
source.TestSettingOf(syspolicy.ExitNodeIP, test.exitNodeIP),
)
syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore)
if test.nm == nil {
@@ -1809,16 +1804,7 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
b.netMap = test.nm
b.pm = pm
b.lastSuggestedExitNode = test.lastSuggestedExitNode
prefs := b.pm.prefs.AsStruct()
if changed := applySysPolicy(prefs, test.lastSuggestedExitNode) || setExitNodeID(prefs, test.nm); changed != test.prefsChanged {
t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed)
}
// Both [LocalBackend.SetPrefsForTest] and [LocalBackend.EditPrefs]
// apply syspolicy settings to the current profile's preferences. Therefore,
// we pass the current, unmodified preferences and expect the effective
// preferences to change.
changed := setExitNodeID(b.pm.prefs.AsStruct(), test.nm, tailcfg.StableNodeID(test.lastSuggestedExitNode))
b.SetPrefsForTest(pm.CurrentPrefs().AsStruct())
if got := b.pm.prefs.ExitNodeID(); got != tailcfg.StableNodeID(test.exitNodeIDWant) {
@@ -1831,6 +1817,10 @@ func TestSetExitNodeIDPolicy(t *testing.T) {
} else if got.String() != test.exitNodeIPWant {
t.Errorf("got %v want %v", got, test.exitNodeIPWant)
}
if changed != test.prefsChanged {
t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed)
}
})
}
}
@@ -2340,7 +2330,7 @@ func TestApplySysPolicy(t *testing.T) {
t.Run("unit", func(t *testing.T) {
prefs := tt.prefs.Clone()
gotAnyChange := applySysPolicy(prefs, "")
gotAnyChange := applySysPolicy(prefs)
if gotAnyChange && prefs.Equals(&tt.prefs) {
t.Errorf("anyChange but prefs is unchanged: %v", prefs.Pretty())
@@ -2488,7 +2478,7 @@ func TestPreferencePolicyInfo(t *testing.T) {
prefs := defaultPrefs.AsStruct()
pp.set(prefs, tt.initialValue)
gotAnyChange := applySysPolicy(prefs, "")
gotAnyChange := applySysPolicy(prefs)
if gotAnyChange != tt.wantChange {
t.Errorf("anyChange=%v, want %v", gotAnyChange, tt.wantChange)
@@ -4118,7 +4108,6 @@ func newLocalBackendWithTestControl(t *testing.T, enableLogging bool, newControl
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(b.Shutdown)
b.DisablePortMapperForTest()
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {
@@ -4475,213 +4464,3 @@ func TestConfigFileReload(t *testing.T) {
t.Fatalf("got %q; want %q", hn, "bar")
}
}
func TestGetVIPServices(t *testing.T) {
tests := []struct {
name string
advertised []string
mapped []string
want []*tailcfg.VIPService
}{
{
"advertised-only",
[]string{"svc:abc", "svc:def"},
[]string{},
[]*tailcfg.VIPService{
{
Name: "svc:abc",
Active: true,
},
{
Name: "svc:def",
Active: true,
},
},
},
{
"mapped-only",
[]string{},
[]string{"svc:abc"},
[]*tailcfg.VIPService{
{
Name: "svc:abc",
Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
},
},
},
{
"mapped-and-advertised",
[]string{"svc:abc"},
[]string{"svc:abc"},
[]*tailcfg.VIPService{
{
Name: "svc:abc",
Active: true,
Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
},
},
},
{
"mapped-and-advertised-separately",
[]string{"svc:def"},
[]string{"svc:abc"},
[]*tailcfg.VIPService{
{
Name: "svc:abc",
Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
},
{
Name: "svc:def",
Active: true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
envknob.Setenv("TS_DEBUG_ALLPORTS_SERVICES", strings.Join(tt.mapped, ","))
prefs := &ipn.Prefs{
AdvertiseServices: tt.advertised,
}
got := vipServicesFromPrefs(prefs.View())
slices.SortFunc(got, func(a, b *tailcfg.VIPService) int {
return strings.Compare(a.Name, b.Name)
})
if !reflect.DeepEqual(tt.want, got) {
t.Logf("want:")
for _, s := range tt.want {
t.Logf("%+v", s)
}
t.Logf("got:")
for _, s := range got {
t.Logf("%+v", s)
}
t.Fail()
return
}
})
}
}
func TestUpdatePrefsOnSysPolicyChange(t *testing.T) {
const enableLogging = false
type fieldChange struct {
name string
want any
}
wantPrefsChanges := func(want ...fieldChange) *wantedNotification {
return &wantedNotification{
name: "Prefs",
cond: func(t testing.TB, actor ipnauth.Actor, n *ipn.Notify) bool {
if n.Prefs != nil {
prefs := reflect.Indirect(reflect.ValueOf(n.Prefs.AsStruct()))
for _, f := range want {
got := prefs.FieldByName(f.name).Interface()
if !reflect.DeepEqual(got, f.want) {
t.Errorf("%v: got %v; want %v", f.name, got, f.want)
}
}
}
return n.Prefs != nil
},
}
}
unexpectedPrefsChange := func(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool {
if n.Prefs != nil {
t.Errorf("Unexpected Prefs: %v", n.Prefs.Pretty())
return true
}
return false
}
tests := []struct {
name string
initialPrefs *ipn.Prefs
stringSettings []source.TestSetting[string]
want *wantedNotification
}{
{
name: "ShieldsUp/True",
stringSettings: []source.TestSetting[string]{source.TestSettingOf(syspolicy.EnableIncomingConnections, "never")},
want: wantPrefsChanges(fieldChange{"ShieldsUp", true}),
},
{
name: "ShieldsUp/False",
initialPrefs: &ipn.Prefs{ShieldsUp: true},
stringSettings: []source.TestSetting[string]{source.TestSettingOf(syspolicy.EnableIncomingConnections, "always")},
want: wantPrefsChanges(fieldChange{"ShieldsUp", false}),
},
{
name: "ExitNodeID",
stringSettings: []source.TestSetting[string]{source.TestSettingOf(syspolicy.ExitNodeID, "foo")},
want: wantPrefsChanges(fieldChange{"ExitNodeID", tailcfg.StableNodeID("foo")}),
},
{
name: "EnableRunExitNode",
stringSettings: []source.TestSetting[string]{source.TestSettingOf(syspolicy.EnableRunExitNode, "always")},
want: wantPrefsChanges(fieldChange{"AdvertiseRoutes", []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}}),
},
{
name: "Multiple",
initialPrefs: &ipn.Prefs{
ExitNodeAllowLANAccess: true,
},
stringSettings: []source.TestSetting[string]{
source.TestSettingOf(syspolicy.EnableServerMode, "always"),
source.TestSettingOf(syspolicy.ExitNodeAllowLANAccess, "never"),
source.TestSettingOf(syspolicy.ExitNodeIP, "127.0.0.1"),
},
want: wantPrefsChanges(
fieldChange{"ForceDaemon", true},
fieldChange{"ExitNodeAllowLANAccess", false},
fieldChange{"ExitNodeIP", netip.MustParseAddr("127.0.0.1")},
),
},
{
name: "NoChange",
initialPrefs: &ipn.Prefs{
CorpDNS: true,
ExitNodeID: "foo",
AdvertiseRoutes: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
},
stringSettings: []source.TestSetting[string]{
source.TestSettingOf(syspolicy.EnableTailscaleDNS, "always"),
source.TestSettingOf(syspolicy.ExitNodeID, "foo"),
source.TestSettingOf(syspolicy.EnableRunExitNode, "always"),
},
want: nil, // syspolicy settings match the preferences; no change notification is expected.
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
syspolicy.RegisterWellKnownSettingsForTest(t)
store := source.NewTestStoreOf[string](t)
syspolicy.MustRegisterStoreForTest(t, "TestSource", setting.DeviceScope, store)
lb := newLocalBackendWithTestControl(t, enableLogging, func(tb testing.TB, opts controlclient.Options) controlclient.Client {
return newClient(tb, opts)
})
if tt.initialPrefs != nil {
lb.SetPrefsForTest(tt.initialPrefs)
}
if err := lb.Start(ipn.Options{}); err != nil {
t.Fatalf("(*LocalBackend).Start(): %v", err)
}
nw := newNotificationWatcher(t, lb, &ipnauth.TestActor{})
if tt.want != nil {
nw.watch(0, []wantedNotification{*tt.want})
} else {
nw.watch(0, nil, unexpectedPrefsChange)
}
store.SetStrings(tt.stringSettings...)
nw.check()
})
}
}

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"runtime"
"slices"
"strings"
@@ -203,6 +204,7 @@ func (pm *profileManager) setUnattendedModeAsConfigured() error {
// Reset unloads the current profile, if any.
func (pm *profileManager) Reset() {
log.Printf("TESTPREFS: Reset")
pm.currentUserID = ""
pm.NewProfile()
}
@@ -215,6 +217,7 @@ func (pm *profileManager) Reset() {
// is logged into so that we can keep track of things like their domain name
// across user switches to disambiguate the same account but a different tailnet.
func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile) error {
log.Printf("TESTPREFS: SetPrefs with prefs %v", prefsIn)
cp := pm.currentProfile
if persist := prefsIn.Persist(); !persist.Valid() || persist.NodeID() == "" || persist.UserProfile().LoginName == "" {
// We don't know anything about this profile, so ignore it for now.
@@ -223,6 +226,7 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile)
// Check if we already have an existing profile that matches the user/node.
if existing := pm.findMatchingProfiles(prefsIn); len(existing) > 0 {
log.Printf("TESTPREFS: SetPrefs found existing profile")
// We already have a profile for this user/node we should reuse it. Also
// cleanup any other duplicate profiles.
cp = existing[0]
@@ -230,6 +234,7 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile)
for _, p := range existing {
// Clear the state.
if err := pm.store.WriteState(p.Key, nil); err != nil {
log.Printf("TESTPREFS: SetPrefs found existing profile, error writing state: %v", err)
// We couldn't delete the state, so keep the profile around.
continue
}
@@ -237,6 +242,8 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile)
// in [profileManager.setProfilePrefs] below.
delete(pm.knownProfiles, p.ID)
}
} else {
log.Printf("TESTPREFS: SetPrefs not found existing profile")
}
pm.currentProfile = cp
if err := pm.SetProfilePrefs(cp, prefsIn, np); err != nil {
@@ -327,6 +334,7 @@ func newUnusedID(knownProfiles map[ipn.ProfileID]*ipn.LoginProfile) (ipn.Profile
// profile, such as verifying the caller's access rights or checking
// if another profile for the same node already exists.
func (pm *profileManager) setProfilePrefsNoPermCheck(profile *ipn.LoginProfile, clonedPrefs ipn.PrefsView) error {
log.Printf("TESTPREFS: setProfilePrefsNoPerm")
isCurrentProfile := pm.currentProfile == profile
if isCurrentProfile {
pm.prefs = clonedPrefs
@@ -423,6 +431,7 @@ func (pm *profileManager) profilePrefs(p *ipn.LoginProfile) (ipn.PrefsView, erro
// If the profile exists but is not accessible to the current user, it returns an [errProfileAccessDenied].
// If the profile does not exist, it returns an [errProfileNotFound].
func (pm *profileManager) SwitchProfile(id ipn.ProfileID) error {
log.Printf("TESTPREFS: SwitchProfile")
metricSwitchProfile.Add(1)
kp, ok := pm.knownProfiles[id]
@@ -450,6 +459,7 @@ func (pm *profileManager) SwitchProfile(id ipn.ProfileID) error {
// It creates a new one and switches to it if the current user does not have a default profile,
// or returns an error if the default profile is inaccessible or could not be loaded.
func (pm *profileManager) SwitchToDefaultProfile() error {
log.Printf("TESTPREFS: SwitchToDefault")
if id := pm.DefaultUserProfileID(pm.currentUserID); id != "" {
return pm.SwitchProfile(id)
}
@@ -547,6 +557,7 @@ func (pm *profileManager) DeleteProfile(id ipn.ProfileID) error {
}
func (pm *profileManager) deleteCurrentProfile() error {
log.Printf("TESTPREFS: deleteCurrent")
if err := pm.checkProfileAccess(pm.currentProfile); err != nil {
return err
}
@@ -627,6 +638,7 @@ func (pm *profileManager) NewProfile() {
// NewProfileForUser is like [profileManager.NewProfile], but it switches to the
// specified user and sets that user as the profile owner for the new profile.
func (pm *profileManager) NewProfileForUser(uid ipn.WindowsUserID) {
log.Printf("TESTPREFS: NewProfileForUser")
pm.currentUserID = uid
metricNewProfile.Add(1)
@@ -641,6 +653,7 @@ func (pm *profileManager) NewProfileForUser(uid ipn.WindowsUserID) {
// newly created profile immediately. It returns the newly created profile on success,
// or an error on failure.
func (pm *profileManager) newProfileWithPrefs(uid ipn.WindowsUserID, prefs ipn.PrefsView, switchNow bool) (*ipn.LoginProfile, error) {
log.Printf("TESTPREFS: newProfileWithPrefs")
metricNewProfile.Add(1)
profile := &ipn.LoginProfile{LocalUserID: uid}
@@ -733,6 +746,7 @@ func newProfileManagerWithGOOS(store ipn.StateStore, logf logger.Logf, ht *healt
if err != nil {
return nil, err
}
log.Printf("TESTPREFS: newProfileWithGOOS with state key %v", stateKey)
knownProfiles, err := readKnownProfiles(store)
if err != nil {
@@ -748,12 +762,15 @@ func newProfileManagerWithGOOS(store ipn.StateStore, logf logger.Logf, ht *healt
}
if stateKey != "" {
log.Printf("TESTPREFS: state key %v exists", stateKey)
for _, v := range knownProfiles {
log.Printf("TESTPREFS: state key %v exists looking at matching profile %s", stateKey, v)
if v.Key == stateKey {
pm.currentProfile = v
}
}
if pm.currentProfile == nil {
log.Printf("TESTPREFS: current profile is nil")
if suf, ok := strings.CutPrefix(string(stateKey), "user-"); ok {
pm.currentUserID = ipn.WindowsUserID(suf)
}
@@ -776,12 +793,14 @@ func newProfileManagerWithGOOS(store ipn.StateStore, logf logger.Logf, ht *healt
// uid passed in from the unix tests. The uid's used for Windows tests
// and runtime must be valid Windows security identifier structures.
} else if len(knownProfiles) == 0 && goos != "windows" && runtime.GOOS != "windows" {
log.Printf("TESTPREFS: no known profiles")
// No known profiles, try a migration.
pm.dlogf("no known profiles; trying to migrate from legacy prefs")
if _, err := pm.migrateFromLegacyPrefs(pm.currentUserID, true); err != nil {
return nil, err
}
} else {
log.Printf("TESTPREFS: newProfileWithGOOS new profile")
pm.NewProfile()
}

View File

@@ -309,7 +309,6 @@ func TestStateMachine(t *testing.T) {
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(b.Shutdown)
b.DisablePortMapperForTest()
var cc, previousCC *mockControl
@@ -943,7 +942,6 @@ func TestEditPrefsHasNoKeys(t *testing.T) {
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(b.Shutdown)
b.hostinfo = &tailcfg.Hostinfo{OS: "testos"}
b.pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
@@ -1025,7 +1023,6 @@ func TestWGEngineStatusRace(t *testing.T) {
sys.Set(eng)
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
c.Assert(err, qt.IsNil)
t.Cleanup(b.Shutdown)
var cc *mockControl
b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) {

View File

@@ -349,7 +349,6 @@ func newTestLocalBackend(t testing.TB) *ipnlocal.LocalBackend {
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
t.Cleanup(lb.Shutdown)
return lb
}

View File

@@ -20,18 +20,8 @@ import (
"tailscale.com/types/logger"
)
const (
// timeout is the timeout for a single state update that includes calls to the API server to write or read a
// state Secret and emit an Event.
timeout = 30 * time.Second
reasonTailscaleStateUpdated = "TailscaledStateUpdated"
reasonTailscaleStateLoaded = "TailscaleStateLoaded"
reasonTailscaleStateUpdateFailed = "TailscaleStateUpdateFailed"
reasonTailscaleStateLoadFailed = "TailscaleStateLoadFailed"
eventTypeWarning = "Warning"
eventTypeNormal = "Normal"
)
// TODO(irbekrm): should we bump this? should we have retries? See tailscale/tailscale#13024
const timeout = 5 * time.Second
// Store is an ipn.StateStore that uses a Kubernetes Secret for persistence.
type Store struct {
@@ -46,7 +36,7 @@ type Store struct {
// New returns a new Store that persists to the named Secret.
func New(_ logger.Logf, secretName string) (*Store, error) {
c, err := kubeclient.New("tailscale-state-store")
c, err := kubeclient.New()
if err != nil {
return nil, err
}
@@ -83,22 +73,13 @@ 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) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer func() {
if err == nil {
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
}
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)
}
} else {
if err := s.client.Event(ctx, eventTypeNormal, reasonTailscaleStateUpdated, "Successfully updated tailscaled state Secret"); err != nil {
log.Printf("kubestore: error creating tailscaled state Event: %v", err)
}
}
cancel()
}()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
secret, err := s.client.GetSecret(ctx, s.secretName)
if err != nil {
@@ -127,7 +108,7 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
Value: map[string][]byte{sanitizeKey(id): bs},
},
}
if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
if err := s.client.JSONPatchSecret(ctx, s.secretName, m); err != nil {
return fmt.Errorf("error patching Secret %s with a /data field: %v", s.secretName, err)
}
return nil
@@ -139,8 +120,8 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
Value: bs,
},
}
if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
return fmt.Errorf("error patching Secret %s with /data/%s field: %v", s.secretName, sanitizeKey(id), err)
if err := s.client.JSONPatchSecret(ctx, s.secretName, m); err != nil {
return fmt.Errorf("error patching Secret %s with /data/%s field", s.secretName, sanitizeKey(id))
}
return nil
}
@@ -151,7 +132,7 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
return err
}
func (s *Store) loadState() (err error) {
func (s *Store) loadState() error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
@@ -160,14 +141,9 @@ func (s *Store) loadState() (err error) {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 404 {
return ipn.ErrStateNotExist
}
if err := s.client.Event(ctx, eventTypeWarning, reasonTailscaleStateLoadFailed, err.Error()); err != nil {
log.Printf("kubestore: error creating Event: %v", err)
}
return err
}
if err := s.client.Event(ctx, eventTypeNormal, reasonTailscaleStateLoaded, "Successfully loaded tailscaled state from Secret"); err != nil {
log.Printf("kubestore: error creating Event: %v", err)
}
log.Printf("TEST: kube store: got secret: %#+v", secret.Data)
s.memory.LoadFromMap(secret.Data)
return nil
}

View File

@@ -7,6 +7,7 @@ package mem
import (
"bytes"
"encoding/json"
"log"
"sync"
xmaps "golang.org/x/exp/maps"
@@ -32,18 +33,21 @@ func (s *Store) String() string { return "mem.Store" }
// ReadState implements the StateStore interface.
// It returns ipn.ErrStateNotExist if the state does not exist.
func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
log.Printf("TEST: ReadState key %v ", id)
s.mu.Lock()
defer s.mu.Unlock()
bs, ok := s.cache[id]
if !ok {
return nil, ipn.ErrStateNotExist
}
log.Printf("TEST: ReadState key %v val %v", id, string(bs))
return bs, nil
}
// WriteState implements the StateStore interface.
// It never returns an error.
func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
log.Printf("TEST: WriteState key %v ", id)
s.mu.Lock()
defer s.mu.Unlock()
if s.cache == nil {
@@ -57,10 +61,12 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
// Any existing content is cleared, and the provided map is
// copied into the cache.
func (s *Store) LoadFromMap(m map[string][]byte) {
log.Printf("Store: LoadFromMap")
s.mu.Lock()
defer s.mu.Unlock()
xmaps.Clear(s.cache)
for k, v := range m {
log.Printf("TEST: setting state key %v %+#v", k, string(v))
mak.Set(&s.cache, ipn.StateKey(k), v)
}
return

View File

@@ -146,7 +146,6 @@ _Appears in:_
| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#pullpolicy-v1-core)_ | Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image | | Enum: [Always Never IfNotPresent] <br /> |
| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#resourcerequirements-v1-core)_ | Container resource requirements.<br />By default Tailscale Kubernetes operator does not apply any resource<br />requirements. The amount of resources required wil depend on the<br />amount of resources the operator needs to parse, usage patterns and<br />cluster size.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources | | |
| `securityContext` _[SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#securitycontext-v1-core)_ | Container security context.<br />Security context specified here will override the security context by the operator.<br />By default the operator:<br />- sets 'privileged: true' for the init container<br />- set NET_ADMIN capability for tailscale container for proxies that<br />are created for Services or Connector.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context | | |
| `debug` _[Debug](#debug)_ | Configuration for enabling extra debug information in the container.<br />Not recommended for production use. | | |
#### DNSConfig
@@ -249,22 +248,6 @@ _Appears in:_
| `nameserver` _[NameserverStatus](#nameserverstatus)_ | Nameserver describes the status of nameserver cluster resources. | | |
#### Debug
_Appears in:_
- [Container](#container)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enable` _boolean_ | Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/<br />and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where<br />9001 is a container port named "debug". The endpoints and their responses<br />may change in backwards incompatible ways in the future, and should not<br />be considered stable.<br />In 1.78.x and 1.80.x, this setting will default to the value of<br />.spec.metrics.enable, and requests to the "metrics" port matching the<br />mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,<br />this setting will default to false, and no requests will be proxied. | | |
#### Env
@@ -326,7 +309,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `enable` _boolean_ | Setting enable to true will make the proxy serve Tailscale metrics<br />at <pod-ip>:9002/metrics.<br />In 1.78.x and 1.80.x, this field also serves as the default value for<br />.spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both<br />fields will independently default to false.<br />Defaults to false. | | |
| `enable` _boolean_ | Setting enable to true will make the proxy serve Tailscale metrics<br />at <pod-ip>:9001/debug/metrics.<br />Defaults to false. | | |
#### Name

View File

@@ -163,12 +163,7 @@ type Pod struct {
type Metrics struct {
// Setting enable to true will make the proxy serve Tailscale metrics
// at <pod-ip>:9002/metrics.
//
// In 1.78.x and 1.80.x, this field also serves as the default value for
// .spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both
// fields will independently default to false.
//
// at <pod-ip>:9001/debug/metrics.
// Defaults to false.
Enable bool `json:"enable"`
}
@@ -214,26 +209,6 @@ type Container struct {
// https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context
// +optional
SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"`
// Configuration for enabling extra debug information in the container.
// Not recommended for production use.
// +optional
Debug *Debug `json:"debug,omitempty"`
}
type Debug struct {
// Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
// and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
// 9001 is a container port named "debug". The endpoints and their responses
// may change in backwards incompatible ways in the future, and should not
// be considered stable.
//
// In 1.78.x and 1.80.x, this setting will default to the value of
// .spec.metrics.enable, and requests to the "metrics" port matching the
// mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
// this setting will default to false, and no requests will be proxied.
//
// +optional
Enable bool `json:"enable"`
}
type Env struct {

View File

@@ -163,11 +163,6 @@ func (in *Container) DeepCopyInto(out *Container) {
*out = new(corev1.SecurityContext)
(*in).DeepCopyInto(*out)
}
if in.Debug != nil {
in, out := &in.Debug, &out.Debug
*out = new(Debug)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Container.
@@ -286,21 +281,6 @@ func (in *DNSConfigStatus) DeepCopy() *DNSConfigStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Debug) DeepCopyInto(out *Debug) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Debug.
func (in *Debug) DeepCopy() *Debug {
if in == nil {
return nil
}
out := new(Debug)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Env) DeepCopyInto(out *Env) {
*out = *in

View File

@@ -102,7 +102,7 @@ type Hijacker struct {
// connection succeeds. In case of success, returns a list with a single
// successful recording attempt and an error channel. If the connection errors
// after having been established, an error is sent down the channel.
type RecorderDialFn func(context.Context, []netip.AddrPort, sessionrecording.DialFunc) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error)
type RecorderDialFn func(context.Context, []netip.AddrPort, func(context.Context, string, string) (net.Conn, error)) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error)
// Hijack hijacks a 'kubectl exec' session and configures for the session
// contents to be sent to a recorder.

View File

@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
@@ -19,7 +20,6 @@ import (
"go.uber.org/zap"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/k8s-operator/sessionrecording/fakes"
"tailscale.com/sessionrecording"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/tstest"
@@ -80,7 +80,7 @@ func Test_Hijacker(t *testing.T) {
h := &Hijacker{
connectToRecorder: func(context.Context,
[]netip.AddrPort,
sessionrecording.DialFunc,
func(context.Context, string, string) (net.Conn, error),
) (wc io.WriteCloser, rec []*tailcfg.SSHRecordingAttempt, _ <-chan error, err error) {
if tt.failRecorderConnect {
err = errors.New("test")

View File

@@ -32,6 +32,9 @@ type Records struct {
// TailscaledConfigFileName returns a tailscaled config file name in
// format expected by containerboot for the given CapVer.
func TailscaledConfigFileName(cap tailcfg.CapabilityVersion) string {
if cap < 95 {
return "tailscaled"
}
return fmt.Sprintf("cap-%v.hujson", cap)
}

View File

@@ -7,9 +7,7 @@
// dependency size for those consumers when adding anything new here.
package kubeapi
import (
"time"
)
import "time"
// Note: The API types are copied from k8s.io/api{,machinery} to not introduce a
// module dependency on the Kubernetes API as it pulls in many more dependencies.
@@ -153,57 +151,6 @@ type Secret struct {
Data map[string][]byte `json:"data,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.
type Event struct {
TypeMeta `json:",inline"`
ObjectMeta `json:"metadata"`
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
Source EventSource `json:"source,omitempty"` // who is emitting this Event
Type string `json:"type,omitempty"` // Normal or Warning
// InvolvedObject is the subject of the Event. `kubectl describe` will, for most object types, display any
// currently present cluster Events matching the object (but you probably want to set UID for this to work).
InvolvedObject ObjectReference `json:"involvedObject"`
Count int32 `json:"count,omitempty"` // how many times Event was observed
FirstTimestamp time.Time `json:"firstTimestamp,omitempty"`
LastTimestamp time.Time `json:"lastTimestamp,omitempty"`
}
// EventSource includes a subset of fields from corev1.EventSource.
// https://github.com/kubernetes/api/blob/6cc44b8953ae704d6d9ec2adf32e7ae19199ea9f/core/v1/types.go#L7007
// It is copied here to avoid having to import kube libraries.
type EventSource struct {
// Component is the name of the component that is emitting the Event.
Component string `json:"component,omitempty"`
}
// ObjectReference contains a subset of fields from corev1.ObjectReference.
// https://github.com/kubernetes/api/blob/6cc44b8953ae704d6d9ec2adf32e7ae19199ea9f/core/v1/types.go#L6902
// It is copied here to avoid having to import kube libraries.
type ObjectReference struct {
// Kind of the referent.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
// +optional
Kind string `json:"kind,omitempty"`
// Namespace of the referent.
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/
// +optional
Namespace string `json:"namespace,omitempty"`
// Name of the referent.
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
// +optional
Name string `json:"name,omitempty"`
// UID of the referent.
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids
// +optional
UID string `json:"uid,omitempty"`
// API version of the referent.
// +optional
APIVersion string `json:"apiVersion,omitempty"`
}
// Status is a return value for calls that don't return other objects.
type Status struct {
TypeMeta `json:",inline"`
@@ -239,6 +186,6 @@ type Status struct {
Code int `json:"code,omitempty"`
}
func (s Status) Error() string {
func (s *Status) Error() string {
return s.Message
}

View File

@@ -23,21 +23,16 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"tailscale.com/kube/kubeapi"
"tailscale.com/tstime"
"tailscale.com/util/multierr"
)
const (
saPath = "/var/run/secrets/kubernetes.io/serviceaccount"
defaultURL = "https://kubernetes.default.svc"
TypeSecrets = "secrets"
typeEvents = "events"
)
// rootPathForTests is set by tests to override the root path to the
@@ -62,13 +57,8 @@ type Client interface {
GetSecret(context.Context, string) (*kubeapi.Secret, 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
// currently running. This is best effort - if the client is not able to create events, this operation will be a
// no-op. If there is already an Event with the given reason for the current Pod, it will get updated (only
// count and timestamp are expected to change), else a new event will be created.
Event(_ context.Context, typ, reason, msg string) error
StrategicMergePatchSecret(context.Context, string, *kubeapi.Secret, string) error
JSONPatchResource(_ context.Context, resourceName string, resourceType string, patches []JSONPatch) error
JSONPatchSecret(context.Context, string, []JSONPatch) error
CheckSecretPermissions(context.Context, string) (bool, bool, error)
SetDialer(dialer func(context.Context, string, string) (net.Conn, error))
SetURL(string)
@@ -76,24 +66,15 @@ type Client interface {
type client struct {
mu sync.Mutex
name string
url string
podName string
podUID string
ns string // Pod namespace
ns string
client *http.Client
token string
tokenExpiry time.Time
cl tstime.Clock
// hasEventsPerms is true if client can emit Events for the Pod in which it runs. If it is set to false any
// calls to Events() will be a no-op.
hasEventsPerms bool
// kubeAPIRequest sends a request to the kube API server. It can set to a fake in tests.
kubeAPIRequest kubeAPIRequestFunc
}
// New returns a new client
func New(name string) (Client, error) {
func New() (Client, error) {
ns, err := readFile("namespace")
if err != nil {
return nil, err
@@ -106,11 +87,9 @@ func New(name string) (Client, error) {
if ok := cp.AppendCertsFromPEM(caCert); !ok {
return nil, fmt.Errorf("kube: error in creating root cert pool")
}
c := &client{
url: defaultURL,
ns: string(ns),
name: name,
cl: tstime.DefaultClock{},
return &client{
url: defaultURL,
ns: string(ns),
client: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
@@ -118,10 +97,7 @@ func New(name string) (Client, error) {
},
},
},
}
c.kubeAPIRequest = newKubeAPIRequest(c)
c.setEventPerms()
return c, nil
}, nil
}
// SetURL sets the URL to use for the Kubernetes API.
@@ -139,14 +115,14 @@ func (c *client) SetDialer(dialer func(ctx context.Context, network, addr string
func (c *client) expireToken() {
c.mu.Lock()
defer c.mu.Unlock()
c.tokenExpiry = c.cl.Now()
c.tokenExpiry = time.Now()
}
func (c *client) getOrRenewToken() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
tk, te := c.token, c.tokenExpiry
if c.cl.Now().Before(te) {
if time.Now().Before(te) {
return tk, nil
}
@@ -155,10 +131,17 @@ func (c *client) getOrRenewToken() (string, error) {
return "", err
}
c.token = string(tkb)
c.tokenExpiry = c.cl.Now().Add(30 * time.Minute)
c.tokenExpiry = time.Now().Add(30 * time.Minute)
return c.token, nil
}
func (c *client) secretURL(name string) string {
if name == "" {
return fmt.Sprintf("%s/api/v1/namespaces/%s/secrets", c.url, c.ns)
}
return fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", c.url, c.ns, name)
}
func getError(resp *http.Response) error {
if resp.StatusCode == 200 || resp.StatusCode == 201 {
// These are the only success codes returned by the Kubernetes API.
@@ -178,41 +161,36 @@ func setHeader(key, value string) func(*http.Request) {
}
}
type kubeAPIRequestFunc func(ctx context.Context, method, url string, in, out any, opts ...func(*http.Request)) error
// newKubeAPIRequest returns a function that can perform an HTTP request to the Kubernetes API.
func newKubeAPIRequest(c *client) kubeAPIRequestFunc {
// If in is not nil, it is expected to be a JSON-encodable object and will be
// sent as the request body.
// If out is not nil, it is expected to be a pointer to an object that can be
// decoded from JSON.
// If the request fails with a 401, the token is expired and a new one is
// requested.
f := func(ctx context.Context, method, url string, in, out any, opts ...func(*http.Request)) error {
req, err := c.newRequest(ctx, method, url, in)
if err != nil {
return err
}
for _, opt := range opts {
opt(req)
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := getError(resp); err != nil {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 401 {
c.expireToken()
}
return err
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
// doRequest performs an HTTP request to the Kubernetes API.
// If in is not nil, it is expected to be a JSON-encodable object and will be
// sent as the request body.
// If out is not nil, it is expected to be a pointer to an object that can be
// decoded from JSON.
// If the request fails with a 401, the token is expired and a new one is
// requested.
func (c *client) doRequest(ctx context.Context, method, url string, in, out any, opts ...func(*http.Request)) error {
req, err := c.newRequest(ctx, method, url, in)
if err != nil {
return err
}
return f
for _, opt := range opts {
opt(req)
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := getError(resp); err != nil {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 401 {
c.expireToken()
}
return err
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
func (c *client) newRequest(ctx context.Context, method, url string, in any) (*http.Request, error) {
@@ -248,7 +226,7 @@ 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.doRequest(ctx, "GET", c.secretURL(name), nil, s); err != nil {
return nil, err
}
return s, nil
@@ -257,16 +235,16 @@ func (c *client) GetSecret(ctx context.Context, name string) (*kubeapi.Secret, e
// 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.doRequest(ctx, "POST", c.secretURL(""), 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.doRequest(ctx, "PUT", c.secretURL(s.Name), s, nil)
}
// JSONPatch is a JSON patch operation.
// It currently (2024-11-15) only supports "add", "remove" and "replace" operations.
// It currently (2023-03-02) only supports "add" and "remove" operations.
//
// https://tools.ietf.org/html/rfc6902
type JSONPatch struct {
@@ -275,22 +253,22 @@ type JSONPatch struct {
Value any `json:"value,omitempty"`
}
// JSONPatchResource updates a resource in the Kubernetes API using a JSON patch.
// It currently (2024-11-15) only supports "add", "remove" and "replace" operations.
func (c *client) JSONPatchResource(ctx context.Context, name, typ string, patches []JSONPatch) error {
for _, p := range patches {
// JSONPatchSecret updates a secret in the Kubernetes API using a JSON patch.
// It currently (2023-03-02) only supports "add" and "remove" operations.
func (c *client) JSONPatchSecret(ctx context.Context, name string, patch []JSONPatch) error {
for _, p := range patch {
if p.Op != "remove" && p.Op != "add" && p.Op != "replace" {
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.doRequest(ctx, "PATCH", c.secretURL(name), patch, 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.secretURL(name)
if fieldManager != "" {
uv := url.Values{
"fieldManager": {fieldManager},
@@ -299,66 +277,7 @@ func (c *client) StrategicMergePatchSecret(ctx context.Context, name string, s *
}
s.Namespace = c.ns
s.Name = name
return c.kubeAPIRequest(ctx, "PATCH", surl, s, nil, setHeader("Content-Type", "application/strategic-merge-patch+json"))
}
// Event tries to ensure an Event associated with the Pod in which we are running. It is best effort - the event will be
// created if the kube client on startup was able to determine the name and UID of this Pod from POD_NAME,POD_UID env
// vars and if permissions check for event creation succeeded. Events are keyed on opts.Reason- if an Event for the
// current Pod with that reason already exists, its count and first timestamp will be updated, else a new Event will be
// created.
func (c *client) Event(ctx context.Context, typ, reason, msg string) error {
if !c.hasEventsPerms {
return nil
}
name := c.nameForEvent(reason)
ev, err := c.getEvent(ctx, name)
now := c.cl.Now()
if err != nil {
if !IsNotFoundErr(err) {
return err
}
// Event not found - create it
ev := kubeapi.Event{
ObjectMeta: kubeapi.ObjectMeta{
Name: name,
Namespace: c.ns,
},
Type: typ,
Reason: reason,
Message: msg,
Source: kubeapi.EventSource{
Component: c.name,
},
InvolvedObject: kubeapi.ObjectReference{
Name: c.podName,
Namespace: c.ns,
UID: c.podUID,
Kind: "Pod",
APIVersion: "v1",
},
FirstTimestamp: now,
LastTimestamp: now,
Count: 1,
}
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
// last timestamp - first timestamp period of time).
count := ev.Count + 1
countPatch := JSONPatch{
Op: "replace",
Value: count,
Path: "/count",
}
tsPatch := JSONPatch{
Op: "replace",
Value: now,
Path: "/lastTimestamp",
}
return c.JSONPatchResource(ctx, name, typeEvents, []JSONPatch{countPatch, tsPatch})
return c.doRequest(ctx, "PATCH", surl, s, nil, setHeader("Content-Type", "application/strategic-merge-patch+json"))
}
// CheckSecretPermissions checks the secret access permissions of the current
@@ -374,7 +293,7 @@ func (c *client) Event(ctx context.Context, typ, reason, msg string) error {
func (c *client) CheckSecretPermissions(ctx context.Context, secretName string) (canPatch, canCreate bool, err error) {
var errs []error
for _, verb := range []string{"get", "update"} {
ok, err := c.checkPermission(ctx, verb, TypeSecrets, secretName)
ok, err := c.checkPermission(ctx, verb, secretName)
if err != nil {
log.Printf("error checking %s permission on secret %s: %v", verb, secretName, err)
} else if !ok {
@@ -384,12 +303,12 @@ func (c *client) CheckSecretPermissions(ctx context.Context, secretName string)
if len(errs) > 0 {
return false, false, multierr.New(errs...)
}
canPatch, err = c.checkPermission(ctx, "patch", TypeSecrets, secretName)
canPatch, err = c.checkPermission(ctx, "patch", secretName)
if err != nil {
log.Printf("error checking patch permission on secret %s: %v", secretName, err)
return false, false, nil
}
canCreate, err = c.checkPermission(ctx, "create", TypeSecrets, secretName)
canCreate, err = c.checkPermission(ctx, "create", secretName)
if err != nil {
log.Printf("error checking create permission on secret %s: %v", secretName, err)
return false, false, nil
@@ -397,64 +316,19 @@ func (c *client) CheckSecretPermissions(ctx context.Context, secretName string)
return canPatch, canCreate, nil
}
func IsNotFoundErr(err error) bool {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 404 {
return true
}
return false
}
// setEventPerms checks whether this client will be able to write tailscaled Events to its Pod and updates the state
// accordingly. If it determines that the client can not write Events, any subsequent calls to client.Event will be a
// no-op.
func (c *client) setEventPerms() {
name := os.Getenv("POD_NAME")
uid := os.Getenv("POD_UID")
hasPerms := false
defer func() {
c.podName = name
c.podUID = uid
c.hasEventsPerms = hasPerms
if !hasPerms {
log.Printf(`kubeclient: this client is not able to write tailscaled Events to the Pod in which it is running.
To help with future debugging you can make it able write Events by giving it get,create,patch permissions for Events in the Pod namespace
and setting POD_NAME, POD_UID env vars for the Pod.`)
}
}()
if name == "" || uid == "" {
return
}
for _, verb := range []string{"get", "create", "patch"} {
can, err := c.checkPermission(context.Background(), verb, typeEvents, "")
if err != nil {
log.Printf("kubeclient: error checking Events permissions: %v", err)
return
}
if !can {
return
}
}
hasPerms = true
return
}
// checkPermission reports whether the current pod has permission to use the given verb (e.g. get, update, patch,
// create) on the given resource type. If name is not an empty string, will check the check will be for resource with
// the given name only.
func (c *client) checkPermission(ctx context.Context, verb, typ, name string) (bool, error) {
ra := map[string]any{
"namespace": c.ns,
"verb": verb,
"resource": typ,
}
if name != "" {
ra["name"] = name
}
// checkPermission reports whether the current pod has permission to use the
// given verb (e.g. get, update, patch, create) on secretName.
func (c *client) checkPermission(ctx context.Context, verb, secretName string) (bool, error) {
sar := map[string]any{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectAccessReview",
"spec": map[string]any{
"resourceAttributes": ra,
"resourceAttributes": map[string]any{
"namespace": c.ns,
"verb": verb,
"resource": "secrets",
"name": secretName,
},
},
}
var res struct {
@@ -463,32 +337,15 @@ func (c *client) checkPermission(ctx context.Context, verb, typ, name string) (b
} `json:"status"`
}
url := c.url + "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews"
if err := c.kubeAPIRequest(ctx, "POST", url, sar, &res); err != nil {
if err := c.doRequest(ctx, "POST", url, sar, &res); err != nil {
return false, err
}
return res.Status.Allowed, nil
}
// 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 {
if name == "" {
return fmt.Sprintf("%s/api/v1/namespaces/%s/%s", c.url, c.ns, typ)
func IsNotFoundErr(err error) bool {
if st, ok := err.(*kubeapi.Status); ok && st.Code == 404 {
return true
}
return fmt.Sprintf("%s/api/v1/namespaces/%s/%s/%s", c.url, c.ns, typ, name)
}
// nameForEvent returns a name for the Event that uniquely identifies Event with that reason for the current Pod.
func (c *client) nameForEvent(reason string) string {
return fmt.Sprintf("%s.%s.%s", c.podName, c.podUID, strings.ToLower(reason))
}
// 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 {
return nil, err
}
return e, nil
return false
}

View File

@@ -1,151 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package kubeclient
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/kube/kubeapi"
"tailscale.com/tstest"
)
func Test_client_Event(t *testing.T) {
cl := &tstest.Clock{}
tests := []struct {
name string
typ string
reason string
msg string
argSets []args
wantErr bool
}{
{
name: "new_event_gets_created",
typ: "Normal",
reason: "TestReason",
msg: "TestMessage",
argSets: []args{
{ // request to GET event returns not found
wantsMethod: "GET",
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
setErr: &kubeapi.Status{Code: 404},
},
{ // sends POST request to create event
wantsMethod: "POST",
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events",
wantsIn: &kubeapi.Event{
ObjectMeta: kubeapi.ObjectMeta{
Name: "test-pod.test-uid.testreason",
Namespace: "test-ns",
},
Type: "Normal",
Reason: "TestReason",
Message: "TestMessage",
Source: kubeapi.EventSource{
Component: "test-client",
},
InvolvedObject: kubeapi.ObjectReference{
Name: "test-pod",
UID: "test-uid",
Namespace: "test-ns",
APIVersion: "v1",
Kind: "Pod",
},
FirstTimestamp: cl.Now(),
LastTimestamp: cl.Now(),
Count: 1,
},
},
},
},
{
name: "existing_event_gets_patched",
typ: "Warning",
reason: "TestReason",
msg: "TestMsg",
argSets: []args{
{ // request to GET event does not error - this is enough to assume that event exists
wantsMethod: "GET",
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
setOut: []byte(`{"count":2}`),
},
{ // sends PATCH request to update the event
wantsMethod: "PATCH",
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
wantsIn: []JSONPatch{
{Op: "replace", Path: "/count", Value: int32(3)},
{Op: "replace", Path: "/lastTimestamp", Value: cl.Now()},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &client{
cl: cl,
name: "test-client",
podName: "test-pod",
podUID: "test-uid",
url: "test-apiserver",
ns: "test-ns",
kubeAPIRequest: fakeKubeAPIRequest(t, tt.argSets),
hasEventsPerms: true,
}
if err := c.Event(context.Background(), tt.typ, tt.reason, tt.msg); (err != nil) != tt.wantErr {
t.Errorf("client.Event() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// args is a set of values for testing a single call to client.kubeAPIRequest.
type args struct {
// wantsMethod is the expected value of 'method' arg.
wantsMethod string
// wantsURL is the expected value of 'url' arg.
wantsURL string
// wantsIn is the expected value of 'in' arg.
wantsIn any
// setOut can be set to a byte slice representing valid JSON. If set 'out' arg will get set to the unmarshalled
// JSON object.
setOut []byte
// setErr is the error that kubeAPIRequest will return.
setErr error
}
// fakeKubeAPIRequest can be used to test that a series of calls to client.kubeAPIRequest gets called with expected
// values and to set these calls to return preconfigured values. 'argSets' should be set to a slice of expected
// arguments and should-be return values of a series of kubeAPIRequest calls.
func fakeKubeAPIRequest(t *testing.T, argSets []args) kubeAPIRequestFunc {
count := 0
f := func(ctx context.Context, gotMethod, gotUrl string, gotIn, gotOut any, opts ...func(*http.Request)) error {
t.Helper()
if count >= len(argSets) {
t.Fatalf("unexpected call to client.kubeAPIRequest, expected %d calls, but got a %dth call", len(argSets), count+1)
}
a := argSets[count]
if gotMethod != a.wantsMethod {
t.Errorf("[%d] got method %q, wants method %q", count, gotMethod, a.wantsMethod)
}
if gotUrl != a.wantsURL {
t.Errorf("[%d] got URL %q, wants URL %q", count, gotUrl, a.wantsURL)
}
if d := cmp.Diff(gotIn, a.wantsIn); d != "" {
t.Errorf("[%d] unexpected payload (-want + got):\n%s", count, d)
}
if len(a.setOut) != 0 {
if err := json.Unmarshal(a.setOut, gotOut); err != nil {
t.Fatalf("[%d] error unmarshalling output: %v", count, err)
}
}
count++
return a.setErr
}
return f
}

View File

@@ -29,11 +29,7 @@ func (fc *FakeClient) SetDialer(dialer func(ctx context.Context, network, addr s
func (fc *FakeClient) StrategicMergePatchSecret(context.Context, string, *kubeapi.Secret, string) error {
return nil
}
func (fc *FakeClient) Event(context.Context, string, string, string) error {
return nil
}
func (fc *FakeClient) JSONPatchResource(context.Context, string, string, []JSONPatch) error {
func (fc *FakeClient) JSONPatchSecret(context.Context, string, []JSONPatch) error {
return nil
}
func (fc *FakeClient) UpdateSecret(context.Context, *kubeapi.Secret) error { return nil }

View File

@@ -12,23 +12,24 @@ See also the dependencies in the [Tailscale CLI][].
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.1.0/LICENSE))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.32.4/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.30.4/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/config](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/config) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/config/v1.27.28/config/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/credentials](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/credentials) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.17.28/credentials/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/ec2/imds) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.16.12/feature/ec2/imds/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.23/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.23/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.16/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.16/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/ini](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/ini) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/ini/v1.8.1/internal/ini/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.32.4/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.12.0/service/internal/accept-encoding/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.30.4/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.11.4/service/internal/accept-encoding/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/internal/presigned-url](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/presigned-url/v1.11.18/service/internal/presigned-url/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssm](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssm) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssm/v1.45.0/service/ssm/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sso](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sso) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sso/v1.22.5/service/sso/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssooidc](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssooidc) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssooidc/v1.26.5/service/ssooidc/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sts](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sts) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.30.4/service/sts/LICENSE.txt))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.22.0/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.22.0/internal/sync/singleflight/LICENSE))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.20.4/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.20.4/internal/sync/singleflight/LICENSE))
- [github.com/bits-and-blooms/bitset](https://pkg.go.dev/github.com/bits-and-blooms/bitset) ([BSD-3-Clause](https://github.com/bits-and-blooms/bitset/blob/v1.13.0/LICENSE))
- [github.com/coder/websocket](https://pkg.go.dev/github.com/coder/websocket) ([ISC](https://github.com/coder/websocket/blob/v1.8.12/LICENSE.txt))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/65c67c9f46e6/LICENSE))
- [github.com/digitalocean/go-smbios/smbios](https://pkg.go.dev/github.com/digitalocean/go-smbios/smbios) ([Apache-2.0](https://github.com/digitalocean/go-smbios/blob/390a4f403a8e/LICENSE.md))
- [github.com/djherbis/times](https://pkg.go.dev/github.com/djherbis/times) ([MIT](https://github.com/djherbis/times/blob/v1.6.0/LICENSE))
@@ -47,9 +48,9 @@ See also the dependencies in the [Tailscale CLI][].
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.4.1/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.11/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.11/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.11/zstd/internal/xxhash/LICENSE.txt))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.8/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.8/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.8/zstd/internal/xxhash/LICENSE.txt))
- [github.com/kortschak/wol](https://pkg.go.dev/github.com/kortschak/wol) ([BSD-3-Clause](https://github.com/kortschak/wol/blob/da482cc4850a/LICENSE))
- [github.com/mdlayher/genetlink](https://pkg.go.dev/github.com/mdlayher/genetlink) ([MIT](https://github.com/mdlayher/genetlink/blob/v1.3.2/LICENSE.md))
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
@@ -73,12 +74,12 @@ See also the dependencies in the [Tailscale CLI][].
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.28.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fc45aab8:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.30.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.9.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.27.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fe59bbe5:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.27.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.8.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.26.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.25.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.20.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.19.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/64c016c92987/LICENSE))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))

View File

@@ -58,9 +58,9 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/kballard/go-shellquote](https://pkg.go.dev/github.com/kballard/go-shellquote) ([MIT](https://github.com/kballard/go-shellquote/blob/95032a82bc51/LICENSE))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.11/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.11/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.11/zstd/internal/xxhash/LICENSE.txt))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.4/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.4/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.4/zstd/internal/xxhash/LICENSE.txt))
- [github.com/kortschak/wol](https://pkg.go.dev/github.com/kortschak/wol) ([BSD-3-Clause](https://github.com/kortschak/wol/blob/da482cc4850a/LICENSE))
- [github.com/kr/fs](https://pkg.go.dev/github.com/kr/fs) ([BSD-3-Clause](https://github.com/kr/fs/blob/v0.1.0/LICENSE))
- [github.com/mattn/go-colorable](https://pkg.go.dev/github.com/mattn/go-colorable) ([MIT](https://github.com/mattn/go-colorable/blob/v0.1.13/LICENSE))
@@ -84,7 +84,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/tailscale/peercred](https://pkg.go.dev/github.com/tailscale/peercred) ([BSD-3-Clause](https://github.com/tailscale/peercred/blob/b535050b2aa4/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/5db17b287bf1/LICENSE))
- [github.com/tailscale/wf](https://pkg.go.dev/github.com/tailscale/wf) ([BSD-3-Clause](https://github.com/tailscale/wf/blob/6fbb0a674ee6/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/4e883d38c8d3/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/799c1978fafc/LICENSE))
- [github.com/tailscale/xnet/webdav](https://pkg.go.dev/github.com/tailscale/xnet/webdav) ([BSD-3-Clause](https://github.com/tailscale/xnet/blob/8497ac4dab2e/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/toqueteos/webbrowser](https://pkg.go.dev/github.com/toqueteos/webbrowser) ([MIT](https://github.com/toqueteos/webbrowser/blob/v1.2.0/LICENSE.md))
@@ -98,8 +98,8 @@ Some packages may only be included on certain architectures or operating systems
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/1b970713:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.27.0:LICENSE))
- [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.16.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.9.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.27.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.7.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.22.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.22.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.16.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE))

View File

@@ -13,22 +13,22 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/alexbrainman/sspi](https://pkg.go.dev/github.com/alexbrainman/sspi) ([BSD-3-Clause](https://github.com/alexbrainman/sspi/blob/1a75b4708caa/LICENSE))
- [github.com/apenwarr/fixconsole](https://pkg.go.dev/github.com/apenwarr/fixconsole) ([Apache-2.0](https://github.com/apenwarr/fixconsole/blob/5a9f6489cc29/LICENSE))
- [github.com/apenwarr/w32](https://pkg.go.dev/github.com/apenwarr/w32) ([BSD-3-Clause](https://github.com/apenwarr/w32/blob/aa00fece76ab/LICENSE))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.32.4/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.30.4/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/config](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/config) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/config/v1.27.28/config/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/credentials](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/credentials) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.17.28/credentials/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/ec2/imds) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.16.12/feature/ec2/imds/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.23/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.23/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.3.16/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.6.16/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/ini](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/ini) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/ini/v1.8.1/internal/ini/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.32.4/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.12.0/service/internal/accept-encoding/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.30.4/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.11.4/service/internal/accept-encoding/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/internal/presigned-url](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/presigned-url/v1.11.18/service/internal/presigned-url/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssm](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssm) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssm/v1.45.0/service/ssm/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sso](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sso) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sso/v1.22.5/service/sso/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssooidc](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssooidc) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssooidc/v1.26.5/service/ssooidc/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sts](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sts) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.30.4/service/sts/LICENSE.txt))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.22.0/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.22.0/internal/sync/singleflight/LICENSE))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.20.4/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.20.4/internal/sync/singleflight/LICENSE))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/65c67c9f46e6/LICENSE))
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/b75a8a7d7eb0/LICENSE))
- [github.com/djherbis/times](https://pkg.go.dev/github.com/djherbis/times) ([MIT](https://github.com/djherbis/times/blob/v1.6.0/LICENSE))
@@ -44,9 +44,9 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.4.1/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.11/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.11/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.11/zstd/internal/xxhash/LICENSE.txt))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.8/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.8/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.8/zstd/internal/xxhash/LICENSE.txt))
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
- [github.com/mdlayher/socket](https://pkg.go.dev/github.com/mdlayher/socket) ([MIT](https://github.com/mdlayher/socket/blob/v0.5.0/LICENSE.md))
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.58/LICENSE))
@@ -66,14 +66,14 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/ae6ca9944745/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/fdeea329fbba/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.28.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fc45aab8:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/fe59bbe5:LICENSE))
- [golang.org/x/image/bmp](https://pkg.go.dev/golang.org/x/image/bmp) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.18.0:LICENSE))
- [golang.org/x/mod](https://pkg.go.dev/golang.org/x/mod) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.19.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.30.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.9.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.27.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.27.0:LICENSE))
- [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.8.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.26.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.25.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.20.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.19.0:LICENSE))
- [golang.zx2c4.com/wintun](https://pkg.go.dev/golang.zx2c4.com/wintun) ([MIT](https://git.zx2c4.com/wintun-go/tree/LICENSE?id=0fa3db229ce2))
- [golang.zx2c4.com/wireguard/windows/tunnel/winipcfg](https://pkg.go.dev/golang.zx2c4.com/wireguard/windows/tunnel/winipcfg) ([MIT](https://git.zx2c4.com/wireguard-windows/tree/COPYING?h=v0.5.3))
- [gopkg.in/Knetic/govaluate.v3](https://pkg.go.dev/gopkg.in/Knetic/govaluate.v3) ([MIT](https://github.com/Knetic/govaluate/blob/v3.0.0/LICENSE))

View File

@@ -136,31 +136,26 @@ func interfaceNameDoesNotNeedCaptiveDetection(ifName string, goos string) bool {
func (d *Detector) detectOnInterface(ctx context.Context, ifIndex int, endpoints []Endpoint) bool {
defer d.httpClient.CloseIdleConnections()
use := min(len(endpoints), 5)
endpoints = endpoints[:use]
d.logf("[v2] %d available captive portal detection endpoints; trying %v", len(endpoints), use)
d.logf("[v2] %d available captive portal detection endpoints: %v", len(endpoints), endpoints)
// We try to detect the captive portal more quickly by making requests to multiple endpoints concurrently.
var wg sync.WaitGroup
resultCh := make(chan bool, len(endpoints))
// Once any goroutine detects a captive portal, we shut down the others.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for _, e := range endpoints {
for i, e := range endpoints {
if i >= 5 {
// Try a maximum of 5 endpoints, break out (returning false) if we run of attempts.
break
}
wg.Add(1)
go func(endpoint Endpoint) {
defer wg.Done()
found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, ifIndex)
if err != nil {
if ctx.Err() == nil {
d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
}
d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
return
}
if found {
cancel() // one match is good enough
resultCh <- true
}
}(e)

View File

@@ -7,12 +7,10 @@ import (
"context"
"runtime"
"sync"
"sync/atomic"
"testing"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/net/netmon"
"tailscale.com/syncs"
"tailscale.com/tstest/nettest"
)
func TestAvailableEndpointsAlwaysAtLeastTwo(t *testing.T) {
@@ -38,46 +36,25 @@ func TestDetectCaptivePortalReturnsFalse(t *testing.T) {
}
}
func TestEndpointsAreUpAndReturnExpectedResponse(t *testing.T) {
nettest.SkipIfNoNetwork(t)
func TestAllEndpointsAreUpAndReturnExpectedResponse(t *testing.T) {
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/13019")
d := NewDetector(t.Logf)
endpoints := availableEndpoints(nil, 0, t.Logf, runtime.GOOS)
t.Logf("testing %d endpoints", len(endpoints))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var good atomic.Bool
var wg sync.WaitGroup
sem := syncs.NewSemaphore(5)
for _, e := range endpoints {
wg.Add(1)
go func(endpoint Endpoint) {
defer wg.Done()
if !sem.AcquireContext(ctx) {
return
}
defer sem.Release()
found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, 0)
if err != nil && ctx.Err() == nil {
t.Logf("verifyCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
found, err := d.verifyCaptivePortalEndpoint(context.Background(), endpoint, 0)
if err != nil {
t.Errorf("verifyCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
}
if found {
t.Logf("verifyCaptivePortalEndpoint with endpoint %v says we're behind a captive portal, but we aren't", endpoint)
return
t.Errorf("verifyCaptivePortalEndpoint with endpoint %v says we're behind a captive portal, but we aren't", endpoint)
}
good.Store(true)
t.Logf("endpoint good: %v", endpoint)
cancel()
}(e)
}
wg.Wait()
if !good.Load() {
t.Errorf("no good endpoints found")
}
}

View File

@@ -56,18 +56,7 @@ func (m *darwinRouteMon) Receive() (message, error) {
if err != nil {
return nil, err
}
msgs, err := func() (msgs []route.Message, err error) {
defer func() {
// TODO(raggi,#14201): remove once we've got a fix from
// golang/go#70528.
msg := recover()
if msg != nil {
msgs = nil
err = fmt.Errorf("panic in route.ParseRIB: %s", msg)
}
}()
return route.ParseRIB(route.RIBTypeRoute, m.buf[:n])
}()
msgs, err := route.ParseRIB(route.RIBTypeRoute, m.buf[:n])
if err != nil {
if debugRouteMessages {
m.logf("read %d bytes (% 02x), failed to parse RIB: %v", n, m.buf[:n], err)

View File

@@ -66,21 +66,15 @@ const (
TailscaleServiceIPv6String = "fd7a:115c:a1e0::53"
)
// IsTailscaleIP reports whether IP is an IP address in a range that
// IsTailscaleIP reports whether ip is an IP address in a range that
// Tailscale assigns from.
func IsTailscaleIP(ip netip.Addr) bool {
if ip.Is4() {
return IsTailscaleIPv4(ip)
return CGNATRange().Contains(ip) && !ChromeOSVMRange().Contains(ip)
}
return TailscaleULARange().Contains(ip)
}
// IsTailscaleIPv4 reports whether an IPv4 IP is an IP address that
// Tailscale assigns from.
func IsTailscaleIPv4(ip netip.Addr) bool {
return CGNATRange().Contains(ip) && !ChromeOSVMRange().Contains(ip)
}
// TailscaleULARange returns the IPv6 Unique Local Address range that
// is the superset range that Tailscale assigns out of.
func TailscaleULARange() netip.Prefix {

View File

@@ -222,71 +222,3 @@ func TestContainsExitRoute(t *testing.T) {
}
}
}
func TestIsTailscaleIPv4(t *testing.T) {
tests := []struct {
in netip.Addr
want bool
}{
{
in: netip.MustParseAddr("100.67.19.57"),
want: true,
},
{
in: netip.MustParseAddr("10.10.10.10"),
want: false,
},
{
in: netip.MustParseAddr("fd7a:115c:a1e0:3f2b:7a1d:4e88:9c2b:7f01"),
want: false,
},
{
in: netip.MustParseAddr("bc9d:0aa0:1f0a:69ab:eb5c:28e0:5456:a518"),
want: false,
},
{
in: netip.MustParseAddr("100.115.92.157"),
want: false,
},
}
for _, tt := range tests {
if got := IsTailscaleIPv4(tt.in); got != tt.want {
t.Errorf("IsTailscaleIPv4(%v) = %v, want %v", tt.in, got, tt.want)
}
}
}
func TestIsTailscaleIP(t *testing.T) {
tests := []struct {
in netip.Addr
want bool
}{
{
in: netip.MustParseAddr("100.67.19.57"),
want: true,
},
{
in: netip.MustParseAddr("10.10.10.10"),
want: false,
},
{
in: netip.MustParseAddr("fd7a:115c:a1e0:3f2b:7a1d:4e88:9c2b:7f01"),
want: true,
},
{
in: netip.MustParseAddr("bc9d:0aa0:1f0a:69ab:eb5c:28e0:5456:a518"),
want: false,
},
{
in: netip.MustParseAddr("100.115.92.157"),
want: false,
},
}
for _, tt := range tests {
if got := IsTailscaleIP(tt.in); got != tt.want {
t.Errorf("IsTailscaleIP(%v) = %v, want %v", tt.in, got, tt.want)
}
}
}

View File

@@ -45,9 +45,6 @@ type derpProber struct {
bwInterval time.Duration
bwProbeSize int64
// Optionally restrict probes to a single regionCode.
regionCode string
// Probe class for fetching & updating the DERP map.
ProbeMap ProbeClass
@@ -100,14 +97,6 @@ func WithTLSProbing(interval time.Duration) DERPOpt {
}
}
// WithRegion restricts probing to the specified region identified by its code
// (e.g. "lax"). This is case sensitive.
func WithRegion(regionCode string) DERPOpt {
return func(d *derpProber) {
d.regionCode = regionCode
}
}
// DERP creates a new derpProber.
//
// If derpMapURL is "local", the DERPMap is fetched via
@@ -146,10 +135,6 @@ func (d *derpProber) probeMapFn(ctx context.Context) error {
defer d.Unlock()
for _, region := range d.lastDERPMap.Regions {
if d.skipRegion(region) {
continue
}
for _, server := range region.Nodes {
labels := Labels{
"region": region.RegionCode,
@@ -331,10 +316,6 @@ func (d *derpProber) updateMap(ctx context.Context) error {
d.lastDERPMapAt = time.Now()
d.nodes = make(map[string]*tailcfg.DERPNode)
for _, reg := range d.lastDERPMap.Regions {
if d.skipRegion(reg) {
continue
}
for _, n := range reg.Nodes {
if existing, ok := d.nodes[n.Name]; ok {
return fmt.Errorf("derpmap has duplicate nodes: %+v and %+v", existing, n)
@@ -357,10 +338,6 @@ func (d *derpProber) ProbeUDP(ipaddr string, port int) ProbeClass {
}
}
func (d *derpProber) skipRegion(region *tailcfg.DERPRegion) bool {
return d.regionCode != "" && region.RegionCode != d.regionCode
}
func derpProbeUDP(ctx context.Context, ipStr string, port int) error {
pc, err := net.ListenPacket("udp", ":0")
if err != nil {

View File

@@ -44,19 +44,6 @@ func TestDerpProber(t *testing.T) {
},
},
},
1: {
RegionID: 1,
RegionCode: "one",
Nodes: []*tailcfg.DERPNode{
{
Name: "n3",
RegionID: 0,
HostName: "derpn3.tailscale.test",
IPv4: "1.1.1.1",
IPv6: "::1",
},
},
},
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -81,7 +68,6 @@ func TestDerpProber(t *testing.T) {
meshProbeFn: func(_, _ string) ProbeClass { return FuncProbe(func(context.Context) error { return nil }) },
nodes: make(map[string]*tailcfg.DERPNode),
probes: make(map[string]*Probe),
regionCode: "zero",
}
if err := dp.probeMapFn(context.Background()); err != nil {
t.Errorf("unexpected probeMapFn() error: %s", err)
@@ -98,9 +84,9 @@ func TestDerpProber(t *testing.T) {
// Add one more node and check that probes got created.
dm.Regions[0].Nodes = append(dm.Regions[0].Nodes, &tailcfg.DERPNode{
Name: "n4",
Name: "n3",
RegionID: 0,
HostName: "derpn4.tailscale.test",
HostName: "derpn3.tailscale.test",
IPv4: "1.1.1.1",
IPv6: "::1",
})
@@ -127,19 +113,6 @@ func TestDerpProber(t *testing.T) {
if len(dp.probes) != 4 {
t.Errorf("unexpected probes: %+v", dp.probes)
}
// Stop filtering regions.
dp.regionCode = ""
if err := dp.probeMapFn(context.Background()); err != nil {
t.Errorf("unexpected probeMapFn() error: %s", err)
}
if len(dp.nodes) != 2 {
t.Errorf("unexpected nodes: %+v", dp.nodes)
}
// 6 regular probes + 2 mesh probe
if len(dp.probes) != 8 {
t.Errorf("unexpected probes: %+v", dp.probes)
}
}
func TestRunDerpProbeNodePair(t *testing.T) {

View File

@@ -7,8 +7,6 @@ package sessionrecording
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
@@ -16,33 +14,12 @@ import (
"net/http"
"net/http/httptrace"
"net/netip"
"sync/atomic"
"time"
"golang.org/x/net/http2"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
"tailscale.com/util/multierr"
)
const (
// Timeout for an individual DialFunc call for a single recorder address.
perDialAttemptTimeout = 5 * time.Second
// Timeout for the V2 API HEAD probe request (supportsV2).
http2ProbeTimeout = 10 * time.Second
// Maximum timeout for trying all available recorders, including V2 API
// probes and dial attempts.
allDialAttemptsTimeout = 30 * time.Second
)
// uploadAckWindow is the period of time to wait for an ackFrame from recorder
// before terminating the connection. This is a variable to allow overriding it
// in tests.
var uploadAckWindow = 30 * time.Second
// DialFunc is a function for dialing the recorder.
type DialFunc func(ctx context.Context, network, host string) (net.Conn, error)
// ConnectToRecorder connects to the recorder at any of the provided addresses.
// It returns the first successful response, or a multierr if all attempts fail.
//
@@ -55,15 +32,19 @@ type DialFunc func(ctx context.Context, network, host string) (net.Conn, error)
// attempts are in order the recorder(s) was attempted. If successful a
// successful connection is made, the last attempt in the slice is the
// attempt for connected recorder.
func ConnectToRecorder(ctx context.Context, recs []netip.AddrPort, dial DialFunc) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error) {
func ConnectToRecorder(ctx context.Context, recs []netip.AddrPort, dial func(context.Context, string, string) (net.Conn, error)) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error) {
if len(recs) == 0 {
return nil, nil, nil, errors.New("no recorders configured")
}
// We use a special context for dialing the recorder, so that we can
// limit the time we spend dialing to 30 seconds and still have an
// unbounded context for the upload.
dialCtx, dialCancel := context.WithTimeout(ctx, allDialAttemptsTimeout)
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
defer dialCancel()
hc, err := SessionRecordingClientForDialer(dialCtx, dial)
if err != nil {
return nil, nil, nil, err
}
var errs []error
var attempts []*tailcfg.SSHRecordingAttempt
@@ -73,230 +54,74 @@ func ConnectToRecorder(ctx context.Context, recs []netip.AddrPort, dial DialFunc
}
attempts = append(attempts, attempt)
var pw io.WriteCloser
var errChan <-chan error
var err error
hc := clientHTTP2(dialCtx, dial)
// We need to probe V2 support using a separate HEAD request. Sending
// an HTTP/2 POST request to a HTTP/1 server will just "hang" until the
// request body is closed (instead of returning a 404 as one would
// expect). Sending a HEAD request without a body does not have that
// problem.
if supportsV2(ctx, hc, ap) {
pw, errChan, err = connectV2(ctx, hc, ap)
} else {
pw, errChan, err = connectV1(ctx, clientHTTP1(dialCtx, dial), ap)
}
// We dial the recorder and wait for it to send a 100-continue
// response before returning from this function. This ensures that
// the recorder is ready to accept the recording.
// got100 is closed when we receive the 100-continue response.
got100 := make(chan struct{})
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
Got100Continue: func() {
close(got100)
},
})
pr, pw := io.Pipe()
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("http://%s:%d/record", ap.Addr(), ap.Port()), pr)
if err != nil {
err = fmt.Errorf("recording: error starting recording on %q: %w", ap, err)
err = fmt.Errorf("recording: error starting recording: %w", err)
attempt.FailureMessage = err.Error()
errs = append(errs, err)
continue
}
// We set the Expect header to 100-continue, so that the recorder
// will send a 100-continue response before it starts reading the
// request body.
req.Header.Set("Expect", "100-continue")
// errChan is used to indicate the result of the request.
errChan := make(chan error, 1)
go func() {
resp, err := hc.Do(req)
if err != nil {
errChan <- fmt.Errorf("recording: error starting recording: %w", err)
return
}
if resp.StatusCode != 200 {
errChan <- fmt.Errorf("recording: unexpected status: %v", resp.Status)
return
}
errChan <- nil
}()
select {
case <-got100:
case err := <-errChan:
// If we get an error before we get the 100-continue response,
// we need to try another recorder.
if err == nil {
// If the error is nil, we got a 200 response, which
// is unexpected as we haven't sent any data yet.
err = errors.New("recording: unexpected EOF")
}
attempt.FailureMessage = err.Error()
errs = append(errs, err)
continue // try the next recorder
}
return pw, attempts, errChan, nil
}
return nil, attempts, nil, multierr.New(errs...)
}
// supportsV2 checks whether a recorder instance supports the /v2/record
// endpoint.
func supportsV2(ctx context.Context, hc *http.Client, ap netip.AddrPort) bool {
ctx, cancel := context.WithTimeout(ctx, http2ProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, httpm.HEAD, fmt.Sprintf("http://%s/v2/record", ap), nil)
if err != nil {
return false
}
resp, err := hc.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK && resp.ProtoMajor > 1
}
// connectV1 connects to the legacy /record endpoint on the recorder. It is
// used for backwards-compatibility with older tsrecorder instances.
//
// On success, it returns a WriteCloser that can be used to upload the
// recording, and a channel that will be sent an error (or nil) when the upload
// fails or completes.
func connectV1(ctx context.Context, hc *http.Client, ap netip.AddrPort) (io.WriteCloser, <-chan error, error) {
// We dial the recorder and wait for it to send a 100-continue
// response before returning from this function. This ensures that
// the recorder is ready to accept the recording.
// got100 is closed when we receive the 100-continue response.
got100 := make(chan struct{})
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
Got100Continue: func() {
close(got100)
},
})
pr, pw := io.Pipe()
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("http://%s/record", ap), pr)
if err != nil {
return nil, nil, err
}
// We set the Expect header to 100-continue, so that the recorder
// will send a 100-continue response before it starts reading the
// request body.
req.Header.Set("Expect", "100-continue")
// errChan is used to indicate the result of the request.
errChan := make(chan error, 1)
go func() {
defer close(errChan)
resp, err := hc.Do(req)
if err != nil {
errChan <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
errChan <- fmt.Errorf("recording: unexpected status: %v", resp.Status)
return
}
}()
select {
case <-got100:
return pw, errChan, nil
case err := <-errChan:
// If we get an error before we get the 100-continue response,
// we need to try another recorder.
if err == nil {
// If the error is nil, we got a 200 response, which
// is unexpected as we haven't sent any data yet.
err = errors.New("recording: unexpected EOF")
}
return nil, nil, err
}
}
// connectV2 connects to the /v2/record endpoint on the recorder over HTTP/2.
// It explicitly tracks ack frames sent in the response and terminates the
// connection if sent recording data is un-acked for uploadAckWindow.
//
// On success, it returns a WriteCloser that can be used to upload the
// recording, and a channel that will be sent an error (or nil) when the upload
// fails or completes.
func connectV2(ctx context.Context, hc *http.Client, ap netip.AddrPort) (io.WriteCloser, <-chan error, error) {
pr, pw := io.Pipe()
upload := &readCounter{r: pr}
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("http://%s/v2/record", ap), upload)
if err != nil {
return nil, nil, err
}
// With HTTP/2, hc.Do will not block while the request body is being sent.
// It will return immediately and allow us to consume the response body at
// the same time.
resp, err := hc.Do(req)
if err != nil {
return nil, nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, nil, fmt.Errorf("recording: unexpected status: %v", resp.Status)
}
errChan := make(chan error, 1)
acks := make(chan int64)
// Read acks from the response and send them to the acks channel.
go func() {
defer close(errChan)
defer close(acks)
defer resp.Body.Close()
defer pw.Close()
dec := json.NewDecoder(resp.Body)
for {
var frame v2ResponseFrame
if err := dec.Decode(&frame); err != nil {
if !errors.Is(err, io.EOF) {
errChan <- fmt.Errorf("recording: unexpected error receiving acks: %w", err)
}
return
}
if frame.Error != "" {
errChan <- fmt.Errorf("recording: received error from the recorder: %q", frame.Error)
return
}
select {
case acks <- frame.Ack:
case <-ctx.Done():
return
}
}
}()
// Track acks from the acks channel.
go func() {
// Hack for tests: some tests modify uploadAckWindow and reset it when
// the test ends. This can race with t.Reset call below. Making a copy
// here is a lazy workaround to not wait for this goroutine to exit in
// the test cases.
uploadAckWindow := uploadAckWindow
// This timer fires if we didn't receive an ack for too long.
t := time.NewTimer(uploadAckWindow)
defer t.Stop()
for {
select {
case <-t.C:
// Close the pipe which terminates the connection and cleans up
// other goroutines. Note that tsrecorder will send us ack
// frames even if there is no new data to ack. This helps
// detect broken recorder connection if the session is idle.
pr.CloseWithError(errNoAcks)
resp.Body.Close()
return
case _, ok := <-acks:
if !ok {
// acks channel closed means that the goroutine reading them
// finished, which means that the request has ended.
return
}
// TODO(awly): limit how far behind the received acks can be. This
// should handle scenarios where a session suddenly dumps a lot of
// output.
t.Reset(uploadAckWindow)
case <-ctx.Done():
return
}
}
}()
return pw, errChan, nil
}
var errNoAcks = errors.New("did not receive ack frames from the recorder in 30s")
type v2ResponseFrame struct {
// Ack is the number of bytes received from the client so far. The bytes
// are not guaranteed to be durably stored yet.
Ack int64 `json:"ack,omitempty"`
// Error is an error encountered while storing the recording. Error is only
// ever set as the last frame in the response.
Error string `json:"error,omitempty"`
}
// readCounter is an io.Reader that counts how many bytes were read.
type readCounter struct {
r io.Reader
sent atomic.Int64
}
func (u *readCounter) Read(buf []byte) (int, error) {
n, err := u.r.Read(buf)
u.sent.Add(int64(n))
return n, err
}
// clientHTTP1 returns a claassic http.Client with a per-dial context. It uses
// dialCtx and adds a 5s timeout to it.
func clientHTTP1(dialCtx context.Context, dial DialFunc) *http.Client {
// SessionRecordingClientForDialer returns an http.Client that uses a clone of
// the provided Dialer's PeerTransport to dial connections. This is used to make
// requests to the session recording server to upload session recordings. It
// uses the provided dialCtx to dial connections, and limits a single dial to 5
// seconds.
func SessionRecordingClientForDialer(dialCtx context.Context, dial func(context.Context, string, string) (net.Conn, error)) (*http.Client, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
perAttemptCtx, cancel := context.WithTimeout(ctx, perDialAttemptTimeout)
perAttemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
go func() {
select {
@@ -307,32 +132,7 @@ func clientHTTP1(dialCtx context.Context, dial DialFunc) *http.Client {
}()
return dial(perAttemptCtx, network, addr)
}
return &http.Client{Transport: tr}
}
// clientHTTP2 is like clientHTTP1 but returns an http.Client suitable for h2c
// requests (HTTP/2 over plaintext). Unfortunately the same client does not
// work for HTTP/1 so we need to split these up.
func clientHTTP2(dialCtx context.Context, dial DialFunc) *http.Client {
return &http.Client{
Transport: &http2.Transport{
// Allow "http://" scheme in URLs.
AllowHTTP: true,
// Pretend like we're using TLS, but actually use the provided
// DialFunc underneath. This is necessary to convince the transport
// to actually dial.
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
perAttemptCtx, cancel := context.WithTimeout(ctx, perDialAttemptTimeout)
defer cancel()
go func() {
select {
case <-perAttemptCtx.Done():
case <-dialCtx.Done():
cancel()
}
}()
return dial(perAttemptCtx, network, addr)
},
},
}
Transport: tr,
}, nil
}

View File

@@ -1,189 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package sessionrecording
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func TestConnectToRecorder(t *testing.T) {
tests := []struct {
desc string
http2 bool
// setup returns a recorder server mux, and a channel which sends the
// hash of the recording uploaded to it. The channel is expected to
// fire only once.
setup func(t *testing.T) (*http.ServeMux, <-chan []byte)
wantErr bool
}{
{
desc: "v1 recorder",
setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) {
uploadHash := make(chan []byte, 1)
mux := http.NewServeMux()
mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) {
hash := sha256.New()
if _, err := io.Copy(hash, r.Body); err != nil {
t.Error(err)
}
uploadHash <- hash.Sum(nil)
})
return mux, uploadHash
},
},
{
desc: "v2 recorder",
http2: true,
setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) {
uploadHash := make(chan []byte, 1)
mux := http.NewServeMux()
mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) {
t.Error("received request to v1 endpoint")
http.Error(w, "not found", http.StatusNotFound)
})
mux.HandleFunc("POST /v2/record", func(w http.ResponseWriter, r *http.Request) {
// Force the status to send to unblock the client waiting
// for it.
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
body := &readCounter{r: r.Body}
hash := sha256.New()
ctx, cancel := context.WithCancel(r.Context())
go func() {
defer cancel()
if _, err := io.Copy(hash, body); err != nil {
t.Error(err)
}
}()
// Send acks for received bytes.
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
enc := json.NewEncoder(w)
outer:
for {
select {
case <-ctx.Done():
break outer
case <-tick.C:
if err := enc.Encode(v2ResponseFrame{Ack: body.sent.Load()}); err != nil {
t.Errorf("writing ack frame: %v", err)
break outer
}
}
}
uploadHash <- hash.Sum(nil)
})
// Probing HEAD endpoint which always returns 200 OK.
mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {})
return mux, uploadHash
},
},
{
desc: "v2 recorder no acks",
http2: true,
wantErr: true,
setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) {
// Make the client no-ack timeout quick for the test.
oldAckWindow := uploadAckWindow
uploadAckWindow = 100 * time.Millisecond
t.Cleanup(func() { uploadAckWindow = oldAckWindow })
uploadHash := make(chan []byte, 1)
mux := http.NewServeMux()
mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) {
t.Error("received request to v1 endpoint")
http.Error(w, "not found", http.StatusNotFound)
})
mux.HandleFunc("POST /v2/record", func(w http.ResponseWriter, r *http.Request) {
// Force the status to send to unblock the client waiting
// for it.
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
// Consume the whole request body but don't send any acks
// back.
hash := sha256.New()
if _, err := io.Copy(hash, r.Body); err != nil {
t.Error(err)
}
// Goes in the channel buffer, non-blocking.
uploadHash <- hash.Sum(nil)
// Block until the parent test case ends to prevent the
// request termination. We want to exercise the ack
// tracking logic specifically.
ctx, cancel := context.WithCancel(r.Context())
t.Cleanup(cancel)
<-ctx.Done()
})
mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {})
return mux, uploadHash
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
mux, uploadHash := tt.setup(t)
srv := httptest.NewUnstartedServer(mux)
if tt.http2 {
// Wire up h2c-compatible HTTP/2 server. This is optional
// because the v1 recorder didn't support HTTP/2 and we try to
// mimic that.
h2s := &http2.Server{}
srv.Config.Handler = h2c.NewHandler(mux, h2s)
if err := http2.ConfigureServer(srv.Config, h2s); err != nil {
t.Errorf("configuring HTTP/2 support in server: %v", err)
}
}
srv.Start()
t.Cleanup(srv.Close)
d := new(net.Dialer)
ctx := context.Background()
w, _, errc, err := ConnectToRecorder(ctx, []netip.AddrPort{netip.MustParseAddrPort(srv.Listener.Addr().String())}, d.DialContext)
if err != nil {
t.Fatalf("ConnectToRecorder: %v", err)
}
// Send some random data and hash it to compare with the recorded
// data hash.
hash := sha256.New()
const numBytes = 1 << 20 // 1MB
if _, err := io.CopyN(io.MultiWriter(w, hash), rand.Reader, numBytes); err != nil {
t.Fatalf("writing recording data: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("closing recording stream: %v", err)
}
if err := <-errc; err != nil && !tt.wantErr {
t.Fatalf("error from the channel: %v", err)
} else if err == nil && tt.wantErr {
t.Fatalf("did not receive expected error from the channel")
}
if recv, sent := <-uploadHash, hash.Sum(nil); !bytes.Equal(recv, sent) {
t.Errorf("mismatch in recording data hash, sent %x, received %x", sent, recv)
}
})
}
}

View File

@@ -1170,7 +1170,7 @@ func (ss *sshSession) run() {
if err != nil && !errors.Is(err, io.EOF) {
isErrBecauseProcessExited := processDone.Load() && errors.Is(err, syscall.EIO)
if !isErrBecauseProcessExited {
logf("stdout copy: %v", err)
logf("stdout copy: %v, %T", err)
ss.cancelCtx(err)
}
}
@@ -1520,14 +1520,9 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) {
go func() {
err := <-errChan
if err == nil {
select {
case <-ss.ctx.Done():
// Success.
ss.logf("recording: finished uploading recording")
return
default:
err = errors.New("recording upload ended before the SSH session")
}
// Success.
ss.logf("recording: finished uploading recording")
return
}
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
lastAttempt := attempts[len(attempts)-1]

View File

@@ -33,8 +33,6 @@ import (
"time"
gossh "github.com/tailscale/golang-x-crypto/ssh"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/memnet"
@@ -483,9 +481,10 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
}
var handler http.HandlerFunc
recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler(w, r)
})
}))
defer recordingServer.Close()
s := &server{
logf: t.Logf,
@@ -534,10 +533,9 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
{
name: "upload-fails-after-starting",
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
r.Body.Read(make([]byte, 1))
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusInternalServerError)
},
sshCommand: "echo hello && sleep 1 && echo world",
wantClientOutput: "\r\n\r\nsession terminated\r\n\r\n",
@@ -550,7 +548,6 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.logf = t.Logf
tstest.Replace(t, &handler, tt.handler)
sc, dc := memnet.NewTCPConn(src, dst, 1024)
var wg sync.WaitGroup
@@ -600,12 +597,12 @@ func TestMultipleRecorders(t *testing.T) {
t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
}
done := make(chan struct{})
recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(done)
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
io.ReadAll(r.Body)
})
w.WriteHeader(http.StatusOK)
}))
defer recordingServer.Close()
badRecorder, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatal(err)
@@ -613,9 +610,15 @@ func TestMultipleRecorders(t *testing.T) {
badRecorderAddr := badRecorder.Addr().String()
badRecorder.Close()
badRecordingServer500 := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
badRecordingServer500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer badRecordingServer500.Close()
badRecordingServer200 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer badRecordingServer200.Close()
s := &server{
logf: t.Logf,
@@ -627,6 +630,7 @@ func TestMultipleRecorders(t *testing.T) {
Recorders: []netip.AddrPort{
netip.MustParseAddrPort(badRecorderAddr),
netip.MustParseAddrPort(badRecordingServer500.Listener.Addr().String()),
netip.MustParseAddrPort(badRecordingServer200.Listener.Addr().String()),
netip.MustParseAddrPort(recordingServer.Listener.Addr().String()),
},
OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{
@@ -697,21 +701,19 @@ func TestSSHRecordingNonInteractive(t *testing.T) {
}
var recording []byte
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer cancel()
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
var err error
recording, err = io.ReadAll(r.Body)
if err != nil {
t.Error(err)
return
}
})
}))
defer recordingServer.Close()
s := &server{
logf: t.Logf,
logf: logger.Discard,
lb: &localState{
sshEnabled: true,
matchingRule: newSSHRule(
@@ -1297,22 +1299,3 @@ func TestStdOsUserUserAssumptions(t *testing.T) {
t.Errorf("os/user.User has %v fields; this package assumes %v", got, want)
}
}
func mockRecordingServer(t *testing.T, handleRecord http.HandlerFunc) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("POST /record", func(http.ResponseWriter, *http.Request) {
t.Errorf("v1 recording endpoint called")
})
mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {})
mux.HandleFunc("POST /v2/record", handleRecord)
h2s := &http2.Server{}
srv := httptest.NewUnstartedServer(h2c.NewHandler(mux, h2s))
if err := http2.ConfigureServer(srv.Config, h2s); err != nil {
t.Errorf("configuring HTTP/2 support in recording server: %v", err)
}
srv.Start()
t.Cleanup(srv.Close)
return srv
}

View File

@@ -142,7 +142,7 @@ type CapabilityVersion int
// - 97: 2024-06-06: Client understands NodeAttrDisableSplitDNSWhenNoCustomResolvers
// - 98: 2024-06-13: iOS/tvOS clients may provide serial number as part of posture information
// - 99: 2024-06-14: Client understands NodeAttrDisableLocalDNSOverrideViaNRPT
// - 100: 2024-06-18: Initial support for filtertype.Match.SrcCaps - actually usable in capver 109 (issue #12542)
// - 100: 2024-06-18: Client supports filtertype.Match.SrcCaps (issue #12542)
// - 101: 2024-07-01: Client supports SSH agent forwarding when handling connections with /bin/su
// - 102: 2024-07-12: NodeAttrDisableMagicSockCryptoRouting support
// - 103: 2024-07-24: Client supports NodeAttrDisableCaptivePortalDetection
@@ -150,9 +150,7 @@ type CapabilityVersion int
// - 105: 2024-08-05: Fixed SSH behavior on systems that use busybox (issue #12849)
// - 106: 2024-09-03: fix panic regression from cryptokey routing change (65fe0ba7b5)
// - 107: 2024-10-30: add App Connector to conffile (PR #13942)
// - 108: 2024-11-08: Client sends ServicesHash in Hostinfo, understands c2n GET /vip-services.
// - 109: 2024-11-18: Client supports filtertype.Match.SrcCaps (issue #12542)
const CurrentCapabilityVersion CapabilityVersion = 109
const CurrentCapabilityVersion CapabilityVersion = 107
type StableID string
@@ -822,7 +820,6 @@ type Hostinfo struct {
Userspace opt.Bool `json:",omitempty"` // if the client is running in userspace (netstack) mode
UserspaceRouter opt.Bool `json:",omitempty"` // if the client's subnet router is running in userspace (netstack) mode
AppConnector opt.Bool `json:",omitempty"` // if the client is running the app-connector service
ServicesHash string `json:",omitempty"` // opaque hash of the most recent list of tailnet services, change in hash indicates config should be fetched via c2n
// Location represents geographical location data about a
// Tailscale host. Location is optional and only set if
@@ -833,26 +830,6 @@ type Hostinfo struct {
// require changes to Hostinfo.Equal.
}
// VIPService represents a service created on a tailnet from the
// perspective of a node providing that service. These services
// have an virtual IP (VIP) address pair distinct from the node's IPs.
type VIPService struct {
// Name is the name of the service, of the form `svc:dns-label`.
// See CheckServiceName for a validation func.
// Name uniquely identifies a service on a particular tailnet,
// and so also corresponds uniquely to the pair of IP addresses
// belonging to the VIP service.
Name string
// Ports specify which ProtoPorts are made available by this node
// on the service's IPs.
Ports []ProtoPortRange
// Active specifies whether new requests for the service should be
// sent to this node by control.
Active bool
}
// TailscaleSSHEnabled reports whether or not this node is acting as a
// Tailscale SSH server.
func (hi *Hostinfo) TailscaleSSHEnabled() bool {
@@ -1452,11 +1429,6 @@ const (
// user groups as Kubernetes user groups. This capability is read by
// peers that are Tailscale Kubernetes operator instances.
PeerCapabilityKubernetes PeerCapability = "tailscale.com/cap/kubernetes"
// PeerCapabilityServicesDestination grants a peer the ability to serve as
// a destination for a set of given VIP services, which is provided as the
// value of this key in NodeCapMap.
PeerCapabilityServicesDestination PeerCapability = "tailscale.com/cap/services-destination"
)
// NodeCapMap is a map of capabilities to their optional values. It is valid for

View File

@@ -183,7 +183,6 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
Userspace opt.Bool
UserspaceRouter opt.Bool
AppConnector opt.Bool
ServicesHash string
Location *Location
}{})

View File

@@ -66,7 +66,6 @@ func TestHostinfoEqual(t *testing.T) {
"Userspace",
"UserspaceRouter",
"AppConnector",
"ServicesHash",
"Location",
}
if have := fieldsOf(reflect.TypeFor[Hostinfo]()); !reflect.DeepEqual(have, hiHandles) {
@@ -241,16 +240,6 @@ func TestHostinfoEqual(t *testing.T) {
&Hostinfo{AppConnector: opt.Bool("false")},
false,
},
{
&Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"},
&Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"},
true,
},
{
&Hostinfo{ServicesHash: "084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0"},
&Hostinfo{},
false,
},
}
for i, tt := range tests {
got := tt.a.Equal(tt.b)

View File

@@ -318,7 +318,6 @@ func (v HostinfoView) Cloud() string { return v.ж.Clou
func (v HostinfoView) Userspace() opt.Bool { return v.ж.Userspace }
func (v HostinfoView) UserspaceRouter() opt.Bool { return v.ж.UserspaceRouter }
func (v HostinfoView) AppConnector() opt.Bool { return v.ж.AppConnector }
func (v HostinfoView) ServicesHash() string { return v.ж.ServicesHash }
func (v HostinfoView) Location() *Location {
if v.ж.Location == nil {
return nil
@@ -366,7 +365,6 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
Userspace opt.Bool
UserspaceRouter opt.Bool
AppConnector opt.Bool
ServicesHash string
Location *Location
}{})

View File

@@ -832,7 +832,7 @@ func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.Machi
w.WriteHeader(200)
for {
if resBytes, ok := s.takeRawMapMessage(req.NodeKey); ok {
if err := s.sendMapMsg(w, compress, resBytes); err != nil {
if err := s.sendMapMsg(w, mkey, compress, resBytes); err != nil {
s.logf("sendMapMsg of raw message: %v", err)
return
}
@@ -864,7 +864,7 @@ func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.Machi
s.logf("json.Marshal: %v", err)
return
}
if err := s.sendMapMsg(w, compress, resBytes); err != nil {
if err := s.sendMapMsg(w, mkey, compress, resBytes); err != nil {
return
}
}
@@ -895,7 +895,7 @@ func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.Machi
}
break keepAliveLoop
case <-keepAliveTimerCh:
if err := s.sendMapMsg(w, compress, keepAliveMsg); err != nil {
if err := s.sendMapMsg(w, mkey, compress, keepAliveMsg); err != nil {
return
}
}
@@ -1060,7 +1060,7 @@ func (s *Server) takeRawMapMessage(nk key.NodePublic) (mapResJSON []byte, ok boo
return mapResJSON, true
}
func (s *Server) sendMapMsg(w http.ResponseWriter, compress bool, msg any) error {
func (s *Server) sendMapMsg(w http.ResponseWriter, mkey key.MachinePublic, compress bool, msg any) error {
resBytes, err := s.encode(compress, msg)
if err != nil {
return err

View File

@@ -6,10 +6,9 @@ package tsweb
import (
"context"
"net/http"
"time"
"tailscale.com/util/ctxkey"
"tailscale.com/util/rands"
"tailscale.com/util/fastuuid"
)
// RequestID is an opaque identifier for a HTTP request, used to correlate
@@ -42,12 +41,10 @@ const RequestIDHeader = "X-Tailscale-Request-Id"
// GenerateRequestID generates a new request ID with the current format.
func GenerateRequestID() RequestID {
// Return a string of the form "REQ-<VersionByte><...>"
// Previously we returned "REQ-1<UUIDString>".
// Now we return "REQ-2" version, where the "2" doubles as the year 2YYY
// in a leading date.
now := time.Now().UTC()
return RequestID("REQ-" + now.Format("20060102150405") + rands.HexString(16))
// REQ-1 indicates the version of the RequestID pattern. It is
// currently arbitrary but allows for forward compatible
// transitions if needed.
return RequestID("REQ-1" + fastuuid.NewUUID().String())
}
// SetRequestID is an HTTP middleware that injects a RequestID in the

View File

@@ -1307,28 +1307,6 @@ func TestBucket(t *testing.T) {
}
}
func TestGenerateRequestID(t *testing.T) {
t0 := time.Now()
got := GenerateRequestID()
t.Logf("Got: %q", got)
if !strings.HasPrefix(string(got), "REQ-2") {
t.Errorf("expect REQ-2 prefix; got %q", got)
}
const wantLen = len("REQ-2024112022140896f8ead3d3f3be27")
if len(got) != wantLen {
t.Fatalf("len = %d; want %d", len(got), wantLen)
}
d := got[len("REQ-"):][:14]
timeBack, err := time.Parse("20060102150405", string(d))
if err != nil {
t.Fatalf("parsing time back: %v", err)
}
elapsed := timeBack.Sub(t0)
if elapsed > 3*time.Second { // allow for slow github actions runners :)
t.Fatalf("time back was %v; want within 3s", elapsed)
}
}
func ExampleMiddlewareStack() {
// setHeader returns a middleware that sets header k = vs.
setHeader := func(k string, vs ...string) Middleware {

56
util/fastuuid/fastuuid.go Normal file
View File

@@ -0,0 +1,56 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package fastuuid implements a UUID construction using an in process CSPRNG.
package fastuuid
import (
crand "crypto/rand"
"encoding/binary"
"io"
"math/rand/v2"
"sync"
"github.com/google/uuid"
)
// NewUUID returns a new UUID using a pool of generators, good for highly
// concurrent use.
func NewUUID() uuid.UUID {
g := pool.Get().(*generator)
defer pool.Put(g)
return g.newUUID()
}
var pool = sync.Pool{
New: func() any {
return newGenerator()
},
}
type generator struct {
rng rand.ChaCha8
}
func seed() [32]byte {
var r [32]byte
if _, err := io.ReadFull(crand.Reader, r[:]); err != nil {
panic(err)
}
return r
}
func newGenerator() *generator {
return &generator{
rng: *rand.NewChaCha8(seed()),
}
}
func (g *generator) newUUID() uuid.UUID {
var u uuid.UUID
binary.NativeEndian.PutUint64(u[:8], g.rng.Uint64())
binary.NativeEndian.PutUint64(u[8:], g.rng.Uint64())
u[6] = (u[6] & 0x0f) | 0x40 // Version 4
u[8] = (u[8] & 0x3f) | 0x80 // Variant 10
return u
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package fastuuid
import (
"testing"
"github.com/google/uuid"
)
func TestNewUUID(t *testing.T) {
g := pool.Get().(*generator)
defer pool.Put(g)
u := g.newUUID()
if u[6] != (u[6]&0x0f)|0x40 {
t.Errorf("version bits are incorrect")
}
if u[8] != (u[8]&0x3f)|0x80 {
t.Errorf("variant bits are incorrect")
}
}
func BenchmarkBasic(b *testing.B) {
b.Run("NewUUID", func(b *testing.B) {
for range b.N {
NewUUID()
}
})
b.Run("uuid.New-unpooled", func(b *testing.B) {
uuid.DisableRandPool()
for range b.N {
uuid.New()
}
})
b.Run("uuid.New-pooled", func(b *testing.B) {
uuid.EnableRandPool()
for range b.N {
uuid.New()
}
})
}
func BenchmarkParallel(b *testing.B) {
b.Run("NewUUID", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
NewUUID()
}
})
})
b.Run("uuid.New-unpooled", func(b *testing.B) {
uuid.DisableRandPool()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
uuid.New()
}
})
})
b.Run("uuid.New-pooled", func(b *testing.B) {
uuid.EnableRandPool()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
uuid.New()
}
})
})
}

View File

@@ -6,7 +6,6 @@
package multierr
import (
"context"
"errors"
"slices"
"strings"
@@ -135,40 +134,3 @@ func Range(err error, fn func(error) bool) bool {
}
return true
}
// DeduplicateContextErrors returns a new slice of errors with at most one
// occurrence of each [context.Canceled] or [context.DeadlineExceeded], if one
// or more of them are present in the input slice.
//
// All other non-nil errors are returned as-is; nil errors are skipped.
func DeduplicateContextErrors(errs []error) []error {
// preserve nil/non-nil distinction
if errs == nil {
return nil
} else if len(errs) == 0 {
return []error{}
}
var (
ret []error
sawCanceled, sawDeadline bool
)
for _, err := range errs {
if err == nil {
continue
}
if errors.Is(err, context.Canceled) {
if sawCanceled {
continue
}
sawCanceled = true
} else if errors.Is(err, context.DeadlineExceeded) {
if sawDeadline {
continue
}
sawDeadline = true
}
ret = append(ret, err)
}
return ret
}

View File

@@ -4,7 +4,6 @@
package multierr_test
import (
"context"
"errors"
"fmt"
"io"
@@ -108,60 +107,6 @@ func TestRange(t *testing.T) {
})), want)
}
func TestDeduplicateContextErrors(t *testing.T) {
testError := errors.New("test error")
tests := []struct {
name string
input []error
want []error
}{
{name: "nil", input: nil, want: nil},
{name: "empty", input: []error{}, want: []error{}},
{name: "single", input: []error{testError}, want: []error{testError}},
{
name: "duplicate_non_context",
input: []error{testError, testError},
want: []error{testError, testError},
},
{
name: "single_context",
input: []error{context.Canceled},
want: []error{context.Canceled},
},
{
name: "duplicate_context",
input: []error{testError, context.Canceled, context.Canceled},
want: []error{testError, context.Canceled},
},
{
name: "duplicate_context_mixed",
input: []error{
testError,
context.Canceled,
context.Canceled,
testError,
context.DeadlineExceeded,
context.DeadlineExceeded,
},
want: []error{
testError,
context.Canceled,
testError,
context.DeadlineExceeded,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := multierr.DeduplicateContextErrors(tt.input)
if diff := cmp.Diff(tt.want, got, cmpopts.EquateErrors()); diff != "" {
t.Errorf("DeduplicateContextErrors() mismatch (-want +got):\n%s", diff)
}
})
}
}
var sink error
func BenchmarkEmpty(b *testing.B) {

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