Compare commits
18 Commits
andrew/dns
...
bradfitz/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c084e3f6ec | ||
|
|
9f33aeb649 | ||
|
|
48343ee673 | ||
|
|
810da91a9e | ||
|
|
d62baa45e6 | ||
|
|
bb3d0cae5f | ||
|
|
00517c8189 | ||
|
|
da70a84a4b | ||
|
|
93db503565 | ||
|
|
c2a7f17f2b | ||
|
|
5cae7c51bf | ||
|
|
f1e1048977 | ||
|
|
3b93fd9c44 | ||
|
|
aefbed323f | ||
|
|
1355f622be | ||
|
|
c3c4c05331 | ||
|
|
8fd471ce57 | ||
|
|
e73cfd9700 |
@@ -61,7 +61,7 @@ func deleteAuthKey(ctx context.Context, secretName string) error {
|
||||
Path: "/data/authkey",
|
||||
},
|
||||
}
|
||||
if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil {
|
||||
if err := kc.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, 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()
|
||||
kc, err = kubeclient.New("tailscale-container")
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating kube client: %v", err)
|
||||
}
|
||||
|
||||
@@ -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.JSONPatchSecret(ctx, ep.stateSecret, []kubeclient.JSONPatch{patch}); err != nil {
|
||||
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
|
||||
return fmt.Errorf("error patching state Secret: %w", err)
|
||||
}
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
|
||||
@@ -29,6 +29,7 @@ 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() {
|
||||
@@ -47,6 +48,9 @@ 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)
|
||||
|
||||
@@ -16,6 +16,9 @@ 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
|
||||
|
||||
@@ -4703,6 +4703,14 @@ rules:
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- events
|
||||
verbs:
|
||||
- create
|
||||
- patch
|
||||
- get
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
|
||||
@@ -30,6 +30,14 @@ 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:
|
||||
|
||||
@@ -24,3 +24,11 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: POD_UID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.uid
|
||||
|
||||
@@ -126,15 +126,6 @@ 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)",
|
||||
@@ -147,10 +138,6 @@ 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,
|
||||
@@ -171,7 +158,7 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode, cfgHa
|
||||
})
|
||||
}
|
||||
|
||||
return envs
|
||||
return append(c.Env, envs...)
|
||||
}()
|
||||
|
||||
return ss, nil
|
||||
@@ -215,6 +202,15 @@ func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role {
|
||||
return secrets
|
||||
}(),
|
||||
},
|
||||
{
|
||||
APIGroups: []string{""},
|
||||
Resources: []string{"events"},
|
||||
Verbs: []string{
|
||||
"create",
|
||||
"patch",
|
||||
"get",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +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: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
|
||||
},
|
||||
@@ -228,6 +230,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: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
|
||||
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"},
|
||||
|
||||
@@ -213,6 +213,7 @@ 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
|
||||
@@ -500,6 +501,7 @@ var watchIPNArgs struct {
|
||||
netmap bool
|
||||
initial bool
|
||||
showPrivateKey bool
|
||||
rateLimit bool
|
||||
count int
|
||||
}
|
||||
|
||||
@@ -511,6 +513,9 @@ 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
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/internal/noiseconn"
|
||||
"tailscale.com/net/dnscache"
|
||||
@@ -30,7 +29,6 @@ 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
|
||||
@@ -107,11 +105,6 @@ 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).
|
||||
//
|
||||
@@ -129,7 +122,7 @@ func NewNoiseClient(opts NoiseOpts) (*NoiseClient, error) {
|
||||
if u.Scheme == "http" {
|
||||
httpPort = port
|
||||
httpsPort = "443"
|
||||
if (testenv.InTest() || controlIsPlaintext()) && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost") {
|
||||
if u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" {
|
||||
httpsPort = ""
|
||||
}
|
||||
} else {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -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-20240905161824-799c1978fafc
|
||||
github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3
|
||||
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
4
go.sum
@@ -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-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/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/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=
|
||||
|
||||
@@ -1 +1 @@
|
||||
bf15628b759344c6fc7763795a405ba65b8be5d7
|
||||
96578f73d04e1a231fa2a495ad3fa97747785bc6
|
||||
|
||||
@@ -73,6 +73,8 @@ 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
|
||||
@@ -100,7 +102,6 @@ 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
|
||||
@@ -173,9 +174,6 @@ 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 ")
|
||||
}
|
||||
|
||||
160
ipn/ipnlocal/bus.go
Normal file
160
ipn/ipnlocal/bus.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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
|
||||
}
|
||||
220
ipn/ipnlocal/bus_test.go
Normal file
220
ipn/ipnlocal/bus_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
@@ -77,6 +77,9 @@ 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)
|
||||
@@ -269,6 +272,12 @@ 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")
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -2156,10 +2157,7 @@ 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{
|
||||
BackendLogID: &blid,
|
||||
Prefs: &prefs,
|
||||
}, allClients)
|
||||
b.sendToLocked(ipn.Notify{Prefs: &prefs}, allClients)
|
||||
|
||||
if !loggedOut && (b.hasNodeKeyLocked() || confWantRunning) {
|
||||
// If we know that we're either logged in or meant to be
|
||||
@@ -2782,20 +2780,17 @@ 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)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case n := <-ch:
|
||||
if !fn(n) {
|
||||
return
|
||||
}
|
||||
}
|
||||
sender := &rateLimitingBusSender{fn: fn}
|
||||
defer sender.close()
|
||||
|
||||
if mask&ipn.NotifyRateLimit != 0 {
|
||||
sender.interval = 3 * time.Second
|
||||
}
|
||||
|
||||
sender.Run(ctx, ch)
|
||||
}
|
||||
|
||||
// pollRequestEngineStatus calls b.e.RequestStatus every 2 seconds until ctx
|
||||
@@ -4888,6 +4883,14 @@ 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
|
||||
@@ -7485,3 +7488,42 @@ 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))
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/drive/driveimpl"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
@@ -4464,3 +4465,90 @@ 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package kubestore
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -19,8 +20,18 @@ import (
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// TODO(irbekrm): should we bump this? should we have retries? See tailscale/tailscale#13024
|
||||
const timeout = 5 * time.Second
|
||||
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"
|
||||
)
|
||||
|
||||
// Store is an ipn.StateStore that uses a Kubernetes Secret for persistence.
|
||||
type Store struct {
|
||||
@@ -35,7 +46,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()
|
||||
c, err := kubeclient.New("tailscale-state-store")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,13 +83,22 @@ 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 {
|
||||
@@ -107,7 +127,7 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
|
||||
Value: map[string][]byte{sanitizeKey(id): bs},
|
||||
},
|
||||
}
|
||||
if err := s.client.JSONPatchSecret(ctx, s.secretName, m); err != nil {
|
||||
if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
|
||||
return fmt.Errorf("error patching Secret %s with a /data field: %v", s.secretName, err)
|
||||
}
|
||||
return nil
|
||||
@@ -119,8 +139,8 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
|
||||
Value: bs,
|
||||
},
|
||||
}
|
||||
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))
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -131,7 +151,7 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) loadState() error {
|
||||
func (s *Store) loadState() (err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -140,8 +160,14 @@ func (s *Store) loadState() 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)
|
||||
}
|
||||
s.memory.LoadFromMap(secret.Data)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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, func(context.Context, string, string) (net.Conn, error)) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error)
|
||||
type RecorderDialFn func(context.Context, []netip.AddrPort, sessionrecording.DialFunc) (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.
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -20,6 +19,7 @@ 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,
|
||||
func(context.Context, string, string) (net.Conn, error),
|
||||
sessionrecording.DialFunc,
|
||||
) (wc io.WriteCloser, rec []*tailcfg.SSHRecordingAttempt, _ <-chan error, err error) {
|
||||
if tt.failRecorderConnect {
|
||||
err = errors.New("test")
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
// 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.
|
||||
@@ -151,6 +153,57 @@ 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"`
|
||||
@@ -186,6 +239,6 @@ type Status struct {
|
||||
Code int `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Status) Error() string {
|
||||
func (s Status) Error() string {
|
||||
return s.Message
|
||||
}
|
||||
|
||||
@@ -23,16 +23,21 @@ 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
|
||||
@@ -57,8 +62,13 @@ 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
|
||||
JSONPatchSecret(context.Context, string, []JSONPatch) error
|
||||
JSONPatchResource(_ context.Context, resourceName string, resourceType string, patches []JSONPatch) error
|
||||
CheckSecretPermissions(context.Context, string) (bool, bool, error)
|
||||
SetDialer(dialer func(context.Context, string, string) (net.Conn, error))
|
||||
SetURL(string)
|
||||
@@ -66,15 +76,24 @@ type Client interface {
|
||||
|
||||
type client struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
url string
|
||||
ns string
|
||||
podName string
|
||||
podUID string
|
||||
ns string // Pod namespace
|
||||
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() (Client, error) {
|
||||
func New(name string) (Client, error) {
|
||||
ns, err := readFile("namespace")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -87,9 +106,11 @@ func New() (Client, error) {
|
||||
if ok := cp.AppendCertsFromPEM(caCert); !ok {
|
||||
return nil, fmt.Errorf("kube: error in creating root cert pool")
|
||||
}
|
||||
return &client{
|
||||
url: defaultURL,
|
||||
ns: string(ns),
|
||||
c := &client{
|
||||
url: defaultURL,
|
||||
ns: string(ns),
|
||||
name: name,
|
||||
cl: tstime.DefaultClock{},
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
@@ -97,7 +118,10 @@ func New() (Client, error) {
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
c.kubeAPIRequest = newKubeAPIRequest(c)
|
||||
c.setEventPerms()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SetURL sets the URL to use for the Kubernetes API.
|
||||
@@ -115,14 +139,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 = time.Now()
|
||||
c.tokenExpiry = c.cl.Now()
|
||||
}
|
||||
|
||||
func (c *client) getOrRenewToken() (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tk, te := c.token, c.tokenExpiry
|
||||
if time.Now().Before(te) {
|
||||
if c.cl.Now().Before(te) {
|
||||
return tk, nil
|
||||
}
|
||||
|
||||
@@ -131,17 +155,10 @@ func (c *client) getOrRenewToken() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
c.token = string(tkb)
|
||||
c.tokenExpiry = time.Now().Add(30 * time.Minute)
|
||||
c.tokenExpiry = c.cl.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.
|
||||
@@ -161,36 +178,41 @@ func setHeader(key, value string) func(*http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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()
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
return f
|
||||
}
|
||||
|
||||
func (c *client) newRequest(ctx context.Context, method, url string, in any) (*http.Request, error) {
|
||||
@@ -226,7 +248,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.doRequest(ctx, "GET", c.secretURL(name), nil, s); err != nil {
|
||||
if err := c.kubeAPIRequest(ctx, "GET", c.resourceURL(name, TypeSecrets), nil, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
@@ -235,16 +257,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.doRequest(ctx, "POST", c.secretURL(""), s, nil)
|
||||
return c.kubeAPIRequest(ctx, "POST", c.resourceURL("", TypeSecrets), s, nil)
|
||||
}
|
||||
|
||||
// UpdateSecret updates a secret in the Kubernetes API.
|
||||
func (c *client) UpdateSecret(ctx context.Context, s *kubeapi.Secret) error {
|
||||
return c.doRequest(ctx, "PUT", c.secretURL(s.Name), s, nil)
|
||||
return c.kubeAPIRequest(ctx, "PUT", c.resourceURL(s.Name, TypeSecrets), s, nil)
|
||||
}
|
||||
|
||||
// JSONPatch is a JSON patch operation.
|
||||
// It currently (2023-03-02) only supports "add" and "remove" operations.
|
||||
// It currently (2024-11-15) only supports "add", "remove" and "replace" operations.
|
||||
//
|
||||
// https://tools.ietf.org/html/rfc6902
|
||||
type JSONPatch struct {
|
||||
@@ -253,22 +275,22 @@ type JSONPatch struct {
|
||||
Value any `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
if p.Op != "remove" && p.Op != "add" && p.Op != "replace" {
|
||||
return fmt.Errorf("unsupported JSON patch operation: %q", p.Op)
|
||||
}
|
||||
}
|
||||
return c.doRequest(ctx, "PATCH", c.secretURL(name), patch, nil, setHeader("Content-Type", "application/json-patch+json"))
|
||||
return c.kubeAPIRequest(ctx, "PATCH", c.resourceURL(name, typ), patches, nil, setHeader("Content-Type", "application/json-patch+json"))
|
||||
}
|
||||
|
||||
// StrategicMergePatchSecret updates a secret in the Kubernetes API using a
|
||||
// strategic merge patch.
|
||||
// If a fieldManager is provided, it will be used to track the patch.
|
||||
func (c *client) StrategicMergePatchSecret(ctx context.Context, name string, s *kubeapi.Secret, fieldManager string) error {
|
||||
surl := c.secretURL(name)
|
||||
surl := c.resourceURL(name, TypeSecrets)
|
||||
if fieldManager != "" {
|
||||
uv := url.Values{
|
||||
"fieldManager": {fieldManager},
|
||||
@@ -277,7 +299,66 @@ func (c *client) StrategicMergePatchSecret(ctx context.Context, name string, s *
|
||||
}
|
||||
s.Namespace = c.ns
|
||||
s.Name = name
|
||||
return c.doRequest(ctx, "PATCH", surl, s, nil, setHeader("Content-Type", "application/strategic-merge-patch+json"))
|
||||
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})
|
||||
}
|
||||
|
||||
// CheckSecretPermissions checks the secret access permissions of the current
|
||||
@@ -293,7 +374,7 @@ func (c *client) StrategicMergePatchSecret(ctx context.Context, name string, s *
|
||||
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, secretName)
|
||||
ok, err := c.checkPermission(ctx, verb, TypeSecrets, secretName)
|
||||
if err != nil {
|
||||
log.Printf("error checking %s permission on secret %s: %v", verb, secretName, err)
|
||||
} else if !ok {
|
||||
@@ -303,12 +384,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", secretName)
|
||||
canPatch, err = c.checkPermission(ctx, "patch", TypeSecrets, 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", secretName)
|
||||
canCreate, err = c.checkPermission(ctx, "create", TypeSecrets, secretName)
|
||||
if err != nil {
|
||||
log.Printf("error checking create permission on secret %s: %v", secretName, err)
|
||||
return false, false, nil
|
||||
@@ -316,36 +397,98 @@ func (c *client) CheckSecretPermissions(ctx context.Context, secretName string)
|
||||
return canPatch, canCreate, nil
|
||||
}
|
||||
|
||||
// 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": map[string]any{
|
||||
"namespace": c.ns,
|
||||
"verb": verb,
|
||||
"resource": "secrets",
|
||||
"name": secretName,
|
||||
},
|
||||
},
|
||||
}
|
||||
var res struct {
|
||||
Status struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
} `json:"status"`
|
||||
}
|
||||
url := c.url + "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews"
|
||||
if err := c.doRequest(ctx, "POST", url, sar, &res); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.Status.Allowed, 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
|
||||
}
|
||||
sar := map[string]any{
|
||||
"apiVersion": "authorization.k8s.io/v1",
|
||||
"kind": "SelfSubjectAccessReview",
|
||||
"spec": map[string]any{
|
||||
"resourceAttributes": ra,
|
||||
},
|
||||
}
|
||||
var res struct {
|
||||
Status struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
} `json:"status"`
|
||||
}
|
||||
url := c.url + "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews"
|
||||
if err := c.kubeAPIRequest(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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
151
kube/kubeclient/client_test.go
Normal file
151
kube/kubeclient/client_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// 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, gotMethod, a.wantsMethod)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -29,7 +29,11 @@ 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) JSONPatchSecret(context.Context, string, []JSONPatch) error {
|
||||
func (fc *FakeClient) Event(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fc *FakeClient) JSONPatchResource(context.Context, string, string, []JSONPatch) error {
|
||||
return nil
|
||||
}
|
||||
func (fc *FakeClient) UpdateSecret(context.Context, *kubeapi.Secret) error { return nil }
|
||||
|
||||
@@ -12,24 +12,23 @@ 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.30.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.32.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.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/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/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.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/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/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.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/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/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))
|
||||
@@ -48,9 +47,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.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/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/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))
|
||||
@@ -74,12 +73,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/+/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/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/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.19.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/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))
|
||||
|
||||
@@ -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.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/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/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/799c1978fafc/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/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.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/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/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))
|
||||
|
||||
@@ -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.30.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.32.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.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/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/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.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/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/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.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/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/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.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/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/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/+/fe59bbe5: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/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.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/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/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.19.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.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))
|
||||
|
||||
@@ -136,26 +136,31 @@ func interfaceNameDoesNotNeedCaptiveDetection(ifName string, goos string) bool {
|
||||
func (d *Detector) detectOnInterface(ctx context.Context, ifIndex int, endpoints []Endpoint) bool {
|
||||
defer d.httpClient.CloseIdleConnections()
|
||||
|
||||
d.logf("[v2] %d available captive portal detection endpoints: %v", len(endpoints), endpoints)
|
||||
use := min(len(endpoints), 5)
|
||||
endpoints = endpoints[:use]
|
||||
d.logf("[v2] %d available captive portal detection endpoints; trying %v", len(endpoints), use)
|
||||
|
||||
// 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))
|
||||
|
||||
for i, e := range endpoints {
|
||||
if i >= 5 {
|
||||
// Try a maximum of 5 endpoints, break out (returning false) if we run of attempts.
|
||||
break
|
||||
}
|
||||
// Once any goroutine detects a captive portal, we shut down the others.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
for _, e := range endpoints {
|
||||
wg.Add(1)
|
||||
go func(endpoint Endpoint) {
|
||||
defer wg.Done()
|
||||
found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, ifIndex)
|
||||
if err != nil {
|
||||
d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
|
||||
if ctx.Err() == nil {
|
||||
d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if found {
|
||||
cancel() // one match is good enough
|
||||
resultCh <- true
|
||||
}
|
||||
}(e)
|
||||
|
||||
@@ -7,10 +7,12 @@ 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) {
|
||||
@@ -36,25 +38,46 @@ func TestDetectCaptivePortalReturnsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllEndpointsAreUpAndReturnExpectedResponse(t *testing.T) {
|
||||
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/13019")
|
||||
func TestEndpointsAreUpAndReturnExpectedResponse(t *testing.T) {
|
||||
nettest.SkipIfNoNetwork(t)
|
||||
|
||||
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()
|
||||
found, err := d.verifyCaptivePortalEndpoint(context.Background(), endpoint, 0)
|
||||
if err != nil {
|
||||
t.Errorf("verifyCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
|
||||
|
||||
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)
|
||||
}
|
||||
if found {
|
||||
t.Errorf("verifyCaptivePortalEndpoint with endpoint %v says we're behind a captive portal, but we aren't", endpoint)
|
||||
t.Logf("verifyCaptivePortalEndpoint with endpoint %v says we're behind a captive portal, but we aren't", endpoint)
|
||||
return
|
||||
}
|
||||
good.Store(true)
|
||||
t.Logf("endpoint good: %v", endpoint)
|
||||
cancel()
|
||||
}(e)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if !good.Load() {
|
||||
t.Errorf("no good endpoints found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,20 +202,6 @@ func (r *Resolver) LookupIP(ctx context.Context, host string) (ip, v6 netip.Addr
|
||||
r.dlogf("returning %d static results", len(allIPs))
|
||||
return
|
||||
}
|
||||
|
||||
// Hard-code this to avoid extra work, DNS fallbacks, etc.
|
||||
if host == "localhost" {
|
||||
r.dlogf("host is localhost")
|
||||
|
||||
// TODO: @raggi mentioned that some distributions don't use
|
||||
// 127.0.0.1 as the localhost IP; should we check the interface
|
||||
// address to determine this?
|
||||
ip = netip.AddrFrom4([4]byte{127, 0, 0, 1})
|
||||
v6 = netip.IPv6Loopback()
|
||||
allIPs = []netip.Addr{ip, v6}
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
ip = ip.Unmap()
|
||||
r.dlogf("%q is an IP", host)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -241,51 +240,3 @@ func TestShouldTryBootstrap(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalhost(t *testing.T) {
|
||||
tstest.Replace(t, &debug, func() bool { return true })
|
||||
|
||||
r := &Resolver{
|
||||
Logf: t.Logf,
|
||||
Forward: &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
// always return an error to force fallback
|
||||
return nil, errors.New("some error")
|
||||
},
|
||||
},
|
||||
LookupIPFallback: func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
t.Errorf("unexpected call to LookupIPFallback(%q)", host)
|
||||
return nil, errors.New("unimplemented")
|
||||
},
|
||||
}
|
||||
|
||||
// Just overriding the 'Dial' function in the *net.Resolver isn't
|
||||
// enough, because the Go resolver will read /etc/hosts and return
|
||||
// localhost from that.
|
||||
//
|
||||
// Abuse the IP cache to insert a fake localhost entry pointing to some
|
||||
// invalid IP; if we get this back, we know that we didn't hit our
|
||||
// hard-coded "localhost" logic.
|
||||
invalid4 := netip.MustParseAddr("169.254.169.254")
|
||||
invalid6 := netip.MustParseAddr("fe80::1")
|
||||
r.addIPCache("localhost", invalid4, invalid6, []netip.Addr{invalid4, invalid6}, 24*time.Hour)
|
||||
|
||||
ip4, ip6, allIPs, err := r.LookupIP(context.Background(), "localhost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
localhost4 := netip.MustParseAddr("127.0.0.1")
|
||||
localhost6 := netip.MustParseAddr("::1")
|
||||
|
||||
if ip4 != localhost4 {
|
||||
t.Errorf("ip4 got %q; want %q", ip4, localhost4)
|
||||
}
|
||||
if ip6 != localhost6 {
|
||||
t.Errorf("ip6 got %q; want %q", ip6, localhost6)
|
||||
}
|
||||
if !slices.Equal(allIPs, []netip.Addr{localhost4, localhost6}) {
|
||||
t.Errorf("allIPs got %q; want %q", allIPs, []netip.Addr{localhost4, localhost6})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ func makeProbePlanInitial(dm *tailcfg.DERPMap, ifState *netmon.State) (plan prob
|
||||
plan = make(probePlan)
|
||||
|
||||
for _, reg := range dm.Regions {
|
||||
if len(reg.Nodes) == 0 {
|
||||
if reg.Avoid || len(reg.Nodes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +401,7 @@ func TestMakeProbePlan(t *testing.T) {
|
||||
basicMap := &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{},
|
||||
}
|
||||
for rid := 1; rid <= 5; rid++ {
|
||||
for rid := 1; rid <= 6; rid++ {
|
||||
var nodes []*tailcfg.DERPNode
|
||||
for nid := 0; nid < rid; nid++ {
|
||||
nodes = append(nodes, &tailcfg.DERPNode{
|
||||
@@ -415,6 +415,7 @@ func TestMakeProbePlan(t *testing.T) {
|
||||
basicMap.Regions[rid] = &tailcfg.DERPRegion{
|
||||
RegionID: rid,
|
||||
Nodes: nodes,
|
||||
Avoid: rid == 6,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ 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
|
||||
|
||||
@@ -97,6 +100,14 @@ 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
|
||||
@@ -135,6 +146,10 @@ 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,
|
||||
@@ -316,6 +331,10 @@ 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)
|
||||
@@ -338,6 +357,10 @@ 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 {
|
||||
|
||||
@@ -44,6 +44,19 @@ 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) {
|
||||
@@ -68,6 +81,7 @@ 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)
|
||||
@@ -84,9 +98,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: "n3",
|
||||
Name: "n4",
|
||||
RegionID: 0,
|
||||
HostName: "derpn3.tailscale.test",
|
||||
HostName: "derpn4.tailscale.test",
|
||||
IPv4: "1.1.1.1",
|
||||
IPv6: "::1",
|
||||
})
|
||||
@@ -113,6 +127,19 @@ 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) {
|
||||
|
||||
@@ -7,6 +7,8 @@ package sessionrecording
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -14,12 +16,33 @@ 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.
|
||||
//
|
||||
@@ -32,19 +55,15 @@ import (
|
||||
// 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 func(context.Context, string, string) (net.Conn, error)) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error) {
|
||||
func ConnectToRecorder(ctx context.Context, recs []netip.AddrPort, dial DialFunc) (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, 30*time.Second)
|
||||
dialCtx, dialCancel := context.WithTimeout(ctx, allDialAttemptsTimeout)
|
||||
defer dialCancel()
|
||||
hc, err := SessionRecordingClientForDialer(dialCtx, dial)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
var attempts []*tailcfg.SSHRecordingAttempt
|
||||
@@ -54,74 +73,230 @@ func ConnectToRecorder(ctx context.Context, recs []netip.AddrPort, dial func(con
|
||||
}
|
||||
attempts = append(attempts, attempt)
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
if err != nil {
|
||||
err = fmt.Errorf("recording: error starting recording: %w", err)
|
||||
err = fmt.Errorf("recording: error starting recording on %q: %w", ap, 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...)
|
||||
}
|
||||
|
||||
// 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()
|
||||
// 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 {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
perAttemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
perAttemptCtx, cancel := context.WithTimeout(ctx, perDialAttemptTimeout)
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
@@ -132,7 +307,32 @@ func SessionRecordingClientForDialer(dialCtx context.Context, dial func(context.
|
||||
}()
|
||||
return dial(perAttemptCtx, network, addr)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
}, nil
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
189
sessionrecording/connect_test.go
Normal file
189
sessionrecording/connect_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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, %T", err)
|
||||
logf("stdout copy: %v", err)
|
||||
ss.cancelCtx(err)
|
||||
}
|
||||
}
|
||||
@@ -1520,9 +1520,14 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) {
|
||||
go func() {
|
||||
err := <-errChan
|
||||
if err == nil {
|
||||
// Success.
|
||||
ss.logf("recording: finished uploading recording")
|
||||
return
|
||||
select {
|
||||
case <-ss.ctx.Done():
|
||||
// Success.
|
||||
ss.logf("recording: finished uploading recording")
|
||||
return
|
||||
default:
|
||||
err = errors.New("recording upload ended before the SSH session")
|
||||
}
|
||||
}
|
||||
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
|
||||
lastAttempt := attempts[len(attempts)-1]
|
||||
|
||||
@@ -33,6 +33,8 @@ 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"
|
||||
@@ -481,10 +483,9 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
var handler http.HandlerFunc
|
||||
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
handler(w, r)
|
||||
}))
|
||||
defer recordingServer.Close()
|
||||
})
|
||||
|
||||
s := &server{
|
||||
logf: t.Logf,
|
||||
@@ -533,9 +534,10 @@ 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",
|
||||
@@ -548,6 +550,7 @@ 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
|
||||
@@ -597,12 +600,12 @@ func TestMultipleRecorders(t *testing.T) {
|
||||
t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(done)
|
||||
io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer recordingServer.Close()
|
||||
w.(http.Flusher).Flush()
|
||||
io.ReadAll(r.Body)
|
||||
})
|
||||
badRecorder, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -610,15 +613,9 @@ func TestMultipleRecorders(t *testing.T) {
|
||||
badRecorderAddr := badRecorder.Addr().String()
|
||||
badRecorder.Close()
|
||||
|
||||
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()
|
||||
badRecordingServer500 := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
s := &server{
|
||||
logf: t.Logf,
|
||||
@@ -630,7 +627,6 @@ 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{
|
||||
@@ -701,19 +697,21 @@ func TestSSHRecordingNonInteractive(t *testing.T) {
|
||||
}
|
||||
var recording []byte
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
recordingServer := mockRecordingServer(t, 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: logger.Discard,
|
||||
logf: t.Logf,
|
||||
lb: &localState{
|
||||
sshEnabled: true,
|
||||
matchingRule: newSSHRule(
|
||||
@@ -1299,3 +1297,22 @@ 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
|
||||
}
|
||||
|
||||
@@ -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: Client supports filtertype.Match.SrcCaps (issue #12542)
|
||||
// - 100: 2024-06-18: Initial support for filtertype.Match.SrcCaps - actually usable in capver 109 (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,7 +150,9 @@ 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)
|
||||
const CurrentCapabilityVersion CapabilityVersion = 107
|
||||
// - 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
|
||||
|
||||
type StableID string
|
||||
|
||||
@@ -820,6 +822,7 @@ 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
|
||||
@@ -830,6 +833,26 @@ 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 {
|
||||
@@ -1429,6 +1452,11 @@ 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
|
||||
|
||||
@@ -183,6 +183,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
|
||||
Userspace opt.Bool
|
||||
UserspaceRouter opt.Bool
|
||||
AppConnector opt.Bool
|
||||
ServicesHash string
|
||||
Location *Location
|
||||
}{})
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ func TestHostinfoEqual(t *testing.T) {
|
||||
"Userspace",
|
||||
"UserspaceRouter",
|
||||
"AppConnector",
|
||||
"ServicesHash",
|
||||
"Location",
|
||||
}
|
||||
if have := fieldsOf(reflect.TypeFor[Hostinfo]()); !reflect.DeepEqual(have, hiHandles) {
|
||||
@@ -240,6 +241,16 @@ 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)
|
||||
|
||||
@@ -318,6 +318,7 @@ 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
|
||||
@@ -365,6 +366,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
|
||||
Userspace opt.Bool
|
||||
UserspaceRouter opt.Bool
|
||||
AppConnector opt.Bool
|
||||
ServicesHash string
|
||||
Location *Location
|
||||
}{})
|
||||
|
||||
|
||||
@@ -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, mkey, compress, resBytes); err != nil {
|
||||
if err := s.sendMapMsg(w, 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, mkey, compress, resBytes); err != nil {
|
||||
if err := s.sendMapMsg(w, 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, mkey, compress, keepAliveMsg); err != nil {
|
||||
if err := s.sendMapMsg(w, 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, mkey key.MachinePublic, compress bool, msg any) error {
|
||||
func (s *Server) sendMapMsg(w http.ResponseWriter, compress bool, msg any) error {
|
||||
resBytes, err := s.encode(compress, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
|
||||
@@ -128,9 +129,10 @@ func Login(logf logger.Logf, srcName string, u *user.User, capLevel CapabilityLe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenCloseOnce := sync.OnceFunc(func() { token.Close() })
|
||||
defer func() {
|
||||
if err != nil {
|
||||
token.Close()
|
||||
tokenCloseOnce()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -162,6 +164,7 @@ func Login(logf logger.Logf, srcName string, u *user.User, capLevel CapabilityLe
|
||||
sessToken.Close()
|
||||
}
|
||||
}()
|
||||
tokenCloseOnce()
|
||||
}
|
||||
|
||||
userProfile, err := winutil.LoadUserProfile(sessToken, u)
|
||||
|
||||
@@ -7,6 +7,7 @@ package version
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tailscaleroot "tailscale.com"
|
||||
@@ -169,3 +170,42 @@ func majorMinorPatch() string {
|
||||
ret, _, _ := strings.Cut(Short(), "-")
|
||||
return ret
|
||||
}
|
||||
|
||||
func isValidLongWithTwoRepos(v string) bool {
|
||||
s := strings.Split(v, "-")
|
||||
if len(s) != 3 {
|
||||
return false
|
||||
}
|
||||
hexChunk := func(s string) bool {
|
||||
if len(s) < 6 {
|
||||
return false
|
||||
}
|
||||
for i := range len(s) {
|
||||
b := s[i]
|
||||
if (b < '0' || b > '9') && (b < 'a' || b > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
v, t, g := s[0], s[1], s[2]
|
||||
if !strings.HasPrefix(t, "t") || !strings.HasPrefix(g, "g") ||
|
||||
!hexChunk(t[1:]) || !hexChunk(g[1:]) {
|
||||
return false
|
||||
}
|
||||
nums := strings.Split(v, ".")
|
||||
if len(nums) != 3 {
|
||||
return false
|
||||
}
|
||||
for i, n := range nums {
|
||||
bits := 8
|
||||
if i == 2 {
|
||||
bits = 16
|
||||
}
|
||||
if _, err := strconv.ParseUint(n, 10, bits); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
17
version/version_checkformat.go
Normal file
17
version/version_checkformat.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build tailscale_go && android
|
||||
|
||||
package version
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
// For official Android builds using the tailscale_go toolchain,
|
||||
// panic if the builder is screwed up and we fail to stamp a valid
|
||||
// version string.
|
||||
if !isValidLongWithTwoRepos(Long()) {
|
||||
panic(fmt.Sprintf("malformed version.Long value %q", Long()))
|
||||
}
|
||||
}
|
||||
27
version/version_internal_test.go
Normal file
27
version/version_internal_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package version
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidLongWithTwoRepos(t *testing.T) {
|
||||
tests := []struct {
|
||||
long string
|
||||
want bool
|
||||
}{
|
||||
{"1.2.3-t01234abcde-g01234abcde", true},
|
||||
{"1.2.259-t01234abcde-g01234abcde", true}, // big patch version
|
||||
{"1.2.3-t01234abcde", false}, // missing repo
|
||||
{"1.2.3-g01234abcde", false}, // missing repo
|
||||
{"-t01234abcde-g01234abcde", false},
|
||||
{"1.2.3", false},
|
||||
{"1.2.3-t01234abcde-g", false},
|
||||
{"1.2.3-t01234abcde-gERRBUILDINFO", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isValidLongWithTwoRepos(tt.long); got != tt.want {
|
||||
t.Errorf("IsValidLongWithTwoRepos(%q) = %v; want %v", tt.long, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,16 +202,17 @@ func New(matches []Match, capTest CapTestFunc, localNets, logIPs *netipx.IPSet,
|
||||
}
|
||||
|
||||
f := &Filter{
|
||||
logf: logf,
|
||||
matches4: matchesFamily(matches, netip.Addr.Is4),
|
||||
matches6: matchesFamily(matches, netip.Addr.Is6),
|
||||
cap4: capMatchesFunc(matches, netip.Addr.Is4),
|
||||
cap6: capMatchesFunc(matches, netip.Addr.Is6),
|
||||
local4: ipset.FalseContainsIPFunc(),
|
||||
local6: ipset.FalseContainsIPFunc(),
|
||||
logIPs4: ipset.FalseContainsIPFunc(),
|
||||
logIPs6: ipset.FalseContainsIPFunc(),
|
||||
state: state,
|
||||
logf: logf,
|
||||
matches4: matchesFamily(matches, netip.Addr.Is4),
|
||||
matches6: matchesFamily(matches, netip.Addr.Is6),
|
||||
cap4: capMatchesFunc(matches, netip.Addr.Is4),
|
||||
cap6: capMatchesFunc(matches, netip.Addr.Is6),
|
||||
local4: ipset.FalseContainsIPFunc(),
|
||||
local6: ipset.FalseContainsIPFunc(),
|
||||
logIPs4: ipset.FalseContainsIPFunc(),
|
||||
logIPs6: ipset.FalseContainsIPFunc(),
|
||||
state: state,
|
||||
srcIPHasCap: capTest,
|
||||
}
|
||||
if localNets != nil {
|
||||
p := localNets.Prefixes()
|
||||
|
||||
Reference in New Issue
Block a user