summaryrefslogtreecommitdiffhomepage
path: root/ipn/store
diff options
context:
space:
mode:
Diffstat (limited to 'ipn/store')
-rw-r--r--ipn/store/awsstore/store_aws.go111
-rw-r--r--ipn/store/awsstore/store_aws_stub.go18
-rw-r--r--ipn/store/awsstore/store_aws_test.go61
-rw-r--r--ipn/store/kubestore/store_kube.go317
-rw-r--r--ipn/store/kubestore/store_kube_test.go723
-rw-r--r--ipn/store/store_aws.go10
6 files changed, 1157 insertions, 83 deletions
diff --git a/ipn/store/awsstore/store_aws.go b/ipn/store/awsstore/store_aws.go
index 0fb78d45a..40bbbf037 100644
--- a/ipn/store/awsstore/store_aws.go
+++ b/ipn/store/awsstore/store_aws.go
@@ -10,7 +10,9 @@ import (
"context"
"errors"
"fmt"
+ "net/url"
"regexp"
+ "strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/arn"
@@ -28,6 +30,14 @@ const (
var parameterNameRx = regexp.MustCompile(parameterNameRxStr)
+// Option defines a functional option type for configuring awsStore.
+type Option func(*storeOptions)
+
+// storeOptions holds optional settings for creating a new awsStore.
+type storeOptions struct {
+ kmsKey string
+}
+
// awsSSMClient is an interface allowing us to mock the couple of
// API calls we are leveraging with the AWSStore provider
type awsSSMClient interface {
@@ -46,6 +56,10 @@ type awsStore struct {
ssmClient awsSSMClient
ssmARN arn.ARN
+ // kmsKey is optional. If empty, the parameter is stored in plaintext.
+ // If non-empty, the parameter is encrypted with this KMS key.
+ kmsKey string
+
memory mem.Store
}
@@ -57,30 +71,80 @@ type awsStore struct {
// Tailscaled to only only store new state in-memory and
// restarting Tailscaled can fail until you delete your state
// from the AWS Parameter Store.
-func New(_ logger.Logf, ssmARN string) (ipn.StateStore, error) {
- return newStore(ssmARN, nil)
+//
+// If you want to specify an optional KMS key,
+// pass one or more Option objects, e.g. awsstore.WithKeyID("alias/my-key").
+func New(_ logger.Logf, ssmARN string, opts ...Option) (ipn.StateStore, error) {
+ // Apply all options to an empty storeOptions
+ var so storeOptions
+ for _, opt := range opts {
+ opt(&so)
+ }
+
+ return newStore(ssmARN, so, nil)
+}
+
+// WithKeyID sets the KMS key to be used for encryption. It can be
+// a KeyID, an alias ("alias/my-key"), or a full ARN.
+//
+// If kmsKey is empty, the Option is a no-op.
+func WithKeyID(kmsKey string) Option {
+ return func(o *storeOptions) {
+ o.kmsKey = kmsKey
+ }
+}
+
+// ParseARNAndOpts parses an ARN and optional URL-encoded parameters
+// from arg.
+func ParseARNAndOpts(arg string) (ssmARN string, opts []Option, err error) {
+ ssmARN = arg
+
+ // Support optional ?url-encoded-parameters.
+ if s, q, ok := strings.Cut(arg, "?"); ok {
+ ssmARN = s
+ q, err := url.ParseQuery(q)
+ if err != nil {
+ return "", nil, err
+ }
+
+ for k := range q {
+ switch k {
+ default:
+ return "", nil, fmt.Errorf("unknown arn option parameter %q", k)
+ case "kmsKey":
+ // We allow an ARN, a key ID, or an alias name for kmsKeyID.
+ // If it doesn't look like an ARN and doesn't have a '/',
+ // prepend "alias/" for KMS alias references.
+ kmsKey := q.Get(k)
+ if kmsKey != "" &&
+ !strings.Contains(kmsKey, "/") &&
+ !strings.HasPrefix(kmsKey, "arn:") {
+ kmsKey = "alias/" + kmsKey
+ }
+ if kmsKey != "" {
+ opts = append(opts, WithKeyID(kmsKey))
+ }
+ }
+ }
+ }
+ return ssmARN, opts, nil
}
// newStore is NewStore, but for tests. If client is non-nil, it's
// used instead of making one.
-func newStore(ssmARN string, client awsSSMClient) (ipn.StateStore, error) {
+func newStore(ssmARN string, so storeOptions, client awsSSMClient) (ipn.StateStore, error) {
s := &awsStore{
ssmClient: client,
+ kmsKey: so.kmsKey,
}
var err error
-
- // Parse the ARN
if s.ssmARN, err = arn.Parse(ssmARN); err != nil {
return nil, fmt.Errorf("unable to parse the ARN correctly: %v", err)
}
-
- // Validate the ARN corresponds to the SSM service
if s.ssmARN.Service != "ssm" {
return nil, fmt.Errorf("invalid service %q, expected 'ssm'", s.ssmARN.Service)
}
-
- // Validate the ARN corresponds to a parameter store resource
if !parameterNameRx.MatchString(s.ssmARN.Resource) {
return nil, fmt.Errorf("invalid resource %q, expected to match %v", s.ssmARN.Resource, parameterNameRxStr)
}
@@ -96,12 +160,11 @@ func newStore(ssmARN string, client awsSSMClient) (ipn.StateStore, error) {
s.ssmClient = ssm.NewFromConfig(cfg)
}
- // Hydrate cache with the potentially current state
+ // Preload existing state, if any
if err := s.LoadState(); err != nil {
return nil, err
}
return s, nil
-
}
// LoadState attempts to read the state from AWS SSM parameter store key.
@@ -172,15 +235,21 @@ func (s *awsStore) persistState() error {
// which is free. However, if it exceeds 4kb it switches the parameter to advanced tiering
// doubling the capacity to 8kb per the following docs:
// https://aws.amazon.com/about-aws/whats-new/2019/08/aws-systems-manager-parameter-store-announces-intelligent-tiering-to-enable-automatic-parameter-tier-selection/
- _, err = s.ssmClient.PutParameter(
- context.TODO(),
- &ssm.PutParameterInput{
- Name: aws.String(s.ParameterName()),
- Value: aws.String(string(bs)),
- Overwrite: aws.Bool(true),
- Tier: ssmTypes.ParameterTierIntelligentTiering,
- Type: ssmTypes.ParameterTypeSecureString,
- },
- )
+ in := &ssm.PutParameterInput{
+ Name: aws.String(s.ParameterName()),
+ Value: aws.String(string(bs)),
+ Overwrite: aws.Bool(true),
+ Tier: ssmTypes.ParameterTierIntelligentTiering,
+ Type: ssmTypes.ParameterTypeSecureString,
+ }
+
+ // If kmsKey is specified, encrypt with that key
+ // NOTE: this input allows any alias, keyID or ARN
+ // If this isn't specified, AWS will use the default KMS key
+ if s.kmsKey != "" {
+ in.KeyId = aws.String(s.kmsKey)
+ }
+
+ _, err = s.ssmClient.PutParameter(context.TODO(), in)
return err
}
diff --git a/ipn/store/awsstore/store_aws_stub.go b/ipn/store/awsstore/store_aws_stub.go
deleted file mode 100644
index 8d2156ce9..000000000
--- a/ipn/store/awsstore/store_aws_stub.go
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-//go:build !linux || ts_omit_aws
-
-package awsstore
-
-import (
- "fmt"
- "runtime"
-
- "tailscale.com/ipn"
- "tailscale.com/types/logger"
-)
-
-func New(logger.Logf, string) (ipn.StateStore, error) {
- return nil, fmt.Errorf("AWS store is not supported on %v", runtime.GOOS)
-}
diff --git a/ipn/store/awsstore/store_aws_test.go b/ipn/store/awsstore/store_aws_test.go
index f6c8fedb3..3382635a7 100644
--- a/ipn/store/awsstore/store_aws_test.go
+++ b/ipn/store/awsstore/store_aws_test.go
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
-//go:build linux
+//go:build linux && !ts_omit_aws
package awsstore
@@ -65,7 +65,11 @@ func TestNewAWSStore(t *testing.T) {
Resource: "parameter/foo",
}
- s, err := newStore(storeParameterARN.String(), mc)
+ opts := storeOptions{
+ kmsKey: "arn:aws:kms:eu-west-1:123456789:key/MyCustomKey",
+ }
+
+ s, err := newStore(storeParameterARN.String(), opts, mc)
if err != nil {
t.Fatalf("creating aws store failed: %v", err)
}
@@ -73,7 +77,7 @@ func TestNewAWSStore(t *testing.T) {
// Build a brand new file store and check that both IDs written
// above are still there.
- s2, err := newStore(storeParameterARN.String(), mc)
+ s2, err := newStore(storeParameterARN.String(), opts, mc)
if err != nil {
t.Fatalf("creating second aws store failed: %v", err)
}
@@ -162,3 +166,54 @@ func testStoreSemantics(t *testing.T, store ipn.StateStore) {
}
}
}
+
+func TestParseARNAndOpts(t *testing.T) {
+ tests := []struct {
+ name string
+ arg string
+ wantARN string
+ wantKey string
+ }{
+ {
+ name: "no-key",
+ arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
+ wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
+ },
+ {
+ name: "custom-key",
+ arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=alias/MyCustomKey",
+ wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
+ wantKey: "alias/MyCustomKey",
+ },
+ {
+ name: "bare-name",
+ arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=Bare",
+ wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
+ wantKey: "alias/Bare",
+ },
+ {
+ name: "arn-arg",
+ arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=arn:foo",
+ wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
+ wantKey: "arn:foo",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ arn, opts, err := ParseARNAndOpts(tt.arg)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if arn != tt.wantARN {
+ t.Errorf("ARN = %q; want %q", arn, tt.wantARN)
+ }
+ var got storeOptions
+ for _, opt := range opts {
+ opt(&got)
+ }
+ if got.kmsKey != tt.wantKey {
+ t.Errorf("kmsKey = %q; want %q", got.kmsKey, tt.wantKey)
+ }
+ })
+ }
+}
diff --git a/ipn/store/kubestore/store_kube.go b/ipn/store/kubestore/store_kube.go
index 462e6d434..ed37f06c2 100644
--- a/ipn/store/kubestore/store_kube.go
+++ b/ipn/store/kubestore/store_kube.go
@@ -13,11 +13,15 @@ import (
"strings"
"time"
+ "tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
+ "tailscale.com/kube/kubetypes"
"tailscale.com/types/logger"
+ "tailscale.com/util/dnsname"
+ "tailscale.com/util/mak"
)
const (
@@ -31,21 +35,37 @@ const (
reasonTailscaleStateLoadFailed = "TailscaleStateLoadFailed"
eventTypeWarning = "Warning"
eventTypeNormal = "Normal"
+
+ keyTLSCert = "tls.crt"
+ keyTLSKey = "tls.key"
)
// Store is an ipn.StateStore that uses a Kubernetes Secret for persistence.
type Store struct {
- client kubeclient.Client
- canPatch bool
- secretName string
+ client kubeclient.Client
+ canPatch bool
+ secretName string // state Secret
+ certShareMode string // 'ro', 'rw', or empty
+ podName string
- // memory holds the latest tailscale state. Writes write state to a kube Secret and memory, Reads read from
- // memory.
+ // memory holds the latest tailscale state. Writes write state to a kube
+ // Secret and memory, Reads read from memory.
memory mem.Store
}
-// New returns a new Store that persists to the named Secret.
-func New(_ logger.Logf, secretName string) (*Store, error) {
+// New returns a new Store that persists state to Kubernets Secret(s).
+// Tailscale state is stored in a Secret named by the secretName parameter.
+// TLS certs are stored and retrieved from state Secret or separate Secrets
+// named after TLS endpoints if running in cert share mode.
+func New(logf logger.Logf, secretName string) (*Store, error) {
+ c, err := newClient()
+ if err != nil {
+ return nil, err
+ }
+ return newWithClient(logf, c, secretName)
+}
+
+func newClient() (kubeclient.Client, error) {
c, err := kubeclient.New("tailscale-state-store")
if err != nil {
return nil, err
@@ -54,6 +74,10 @@ func New(_ logger.Logf, secretName string) (*Store, error) {
// Derive the API server address from the environment variables
c.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
+ return c, nil
+}
+
+func newWithClient(logf logger.Logf, c kubeclient.Client, secretName string) (*Store, error) {
canPatch, _, err := c.CheckSecretPermissions(context.Background(), secretName)
if err != nil {
return nil, err
@@ -62,11 +86,30 @@ func New(_ logger.Logf, secretName string) (*Store, error) {
client: c,
canPatch: canPatch,
secretName: secretName,
+ podName: os.Getenv("POD_NAME"),
+ }
+ if envknob.IsCertShareReadWriteMode() {
+ s.certShareMode = "rw"
+ } else if envknob.IsCertShareReadOnlyMode() {
+ s.certShareMode = "ro"
}
+
// Load latest state from kube Secret if it already exists.
if err := s.loadState(); err != nil && err != ipn.ErrStateNotExist {
return nil, fmt.Errorf("error loading state from kube Secret: %w", err)
}
+ // If we are in cert share mode, pre-load existing shared certs.
+ if s.certShareMode == "rw" || s.certShareMode == "ro" {
+ sel := s.certSecretSelector()
+ if err := s.loadCerts(context.Background(), sel); err != nil {
+ // We will attempt to again retrieve the certs from Secrets when a request for an HTTPS endpoint
+ // is received.
+ log.Printf("[unexpected] error loading TLS certs: %v", err)
+ }
+ }
+ if s.certShareMode == "ro" {
+ go s.runCertReload(context.Background(), logf)
+ }
return s, nil
}
@@ -83,11 +126,101 @@ func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
// WriteState implements the StateStore interface.
func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
- ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer func() {
if err == nil {
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
}
+ }()
+ return s.updateSecret(map[string][]byte{string(id): bs}, s.secretName)
+}
+
+// WriteTLSCertAndKey writes a TLS cert and key to domain.crt, domain.key fields
+// of a Tailscale Kubernetes node's state Secret.
+func (s *Store) WriteTLSCertAndKey(domain string, cert, key []byte) (err error) {
+ if s.certShareMode == "ro" {
+ log.Printf("[unexpected] TLS cert and key write in read-only mode")
+ }
+ if err := dnsname.ValidHostname(domain); err != nil {
+ return fmt.Errorf("invalid domain name %q: %w", domain, err)
+ }
+ defer func() {
+ // TODO(irbekrm): a read between these two separate writes would
+ // get a mismatched cert and key. Allow writing both cert and
+ // key to the memory store in a single, lock-protected operation.
+ if err == nil {
+ s.memory.WriteState(ipn.StateKey(domain+".crt"), cert)
+ s.memory.WriteState(ipn.StateKey(domain+".key"), key)
+ }
+ }()
+ secretName := s.secretName
+ data := map[string][]byte{
+ domain + ".crt": cert,
+ domain + ".key": key,
+ }
+ // If we run in cert share mode, cert and key for a DNS name are written
+ // to a separate Secret.
+ if s.certShareMode == "rw" {
+ secretName = domain
+ data = map[string][]byte{
+ keyTLSCert: cert,
+ keyTLSKey: key,
+ }
+ }
+ return s.updateSecret(data, secretName)
+}
+
+// ReadTLSCertAndKey reads a TLS cert and key from memory or from a
+// domain-specific Secret. It first checks the in-memory store, if not found in
+// memory and running cert store in read-only mode, looks up a Secret.
+func (s *Store) ReadTLSCertAndKey(domain string) (cert, key []byte, err error) {
+ if err := dnsname.ValidHostname(domain); err != nil {
+ return nil, nil, fmt.Errorf("invalid domain name %q: %w", domain, err)
+ }
+ certKey := domain + ".crt"
+ keyKey := domain + ".key"
+
+ cert, err = s.memory.ReadState(ipn.StateKey(certKey))
+ if err == nil {
+ key, err = s.memory.ReadState(ipn.StateKey(keyKey))
+ if err == nil {
+ return cert, key, nil
+ }
+ }
+ if s.certShareMode != "ro" {
+ return nil, nil, ipn.ErrStateNotExist
+ }
+ // If we are in cert share read only mode, it is possible that a write
+ // replica just issued the TLS cert for this DNS name and it has not
+ // been loaded to store yet, so check the Secret.
+
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ secret, err := s.client.GetSecret(ctx, domain)
+ if err != nil {
+ if kubeclient.IsNotFoundErr(err) {
+ // TODO(irbekrm): we should return a more specific error
+ // that wraps ipn.ErrStateNotExist here.
+ return nil, nil, ipn.ErrStateNotExist
+ }
+ return nil, nil, fmt.Errorf("getting TLS Secret %q: %w", domain, err)
+ }
+ cert = secret.Data[keyTLSCert]
+ key = secret.Data[keyTLSKey]
+ if len(cert) == 0 || len(key) == 0 {
+ return nil, nil, ipn.ErrStateNotExist
+ }
+ // TODO(irbekrm): a read between these two separate writes would
+ // get a mismatched cert and key. Allow writing both cert and
+ // key to the memory store in a single lock-protected operation.
+ s.memory.WriteState(ipn.StateKey(certKey), cert)
+ s.memory.WriteState(ipn.StateKey(keyKey), key)
+ return cert, key, nil
+}
+
+func (s *Store) updateSecret(data map[string][]byte, secretName string) (err error) {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer func() {
if err != nil {
if err := s.client.Event(ctx, eventTypeWarning, reasonTailscaleStateUpdateFailed, err.Error()); err != nil {
log.Printf("kubestore: error creating tailscaled state update Event: %v", err)
@@ -99,56 +232,69 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
}
cancel()
}()
-
- secret, err := s.client.GetSecret(ctx, s.secretName)
+ secret, err := s.client.GetSecret(ctx, secretName)
if err != nil {
- if kubeclient.IsNotFoundErr(err) {
+ // If the Secret does not exist, create it with the required data.
+ if kubeclient.IsNotFoundErr(err) && s.canCreateSecret(secretName) {
return s.client.CreateSecret(ctx, &kubeapi.Secret{
TypeMeta: kubeapi.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: kubeapi.ObjectMeta{
- Name: s.secretName,
- },
- Data: map[string][]byte{
- sanitizeKey(id): bs,
+ Name: secretName,
},
+ Data: func(m map[string][]byte) map[string][]byte {
+ d := make(map[string][]byte, len(m))
+ for key, val := range m {
+ d[sanitizeKey(key)] = val
+ }
+ return d
+ }(data),
})
}
- return err
+ return fmt.Errorf("error getting Secret %s: %w", secretName, err)
}
- if s.canPatch {
- if len(secret.Data) == 0 { // if user has pre-created a blank Secret
- m := []kubeclient.JSONPatch{
+ if s.canPatchSecret(secretName) {
+ var m []kubeclient.JSONPatch
+ // If the user has pre-created a Secret with no data, we need to ensure the top level /data field.
+ if len(secret.Data) == 0 {
+ m = []kubeclient.JSONPatch{
{
- Op: "add",
- Path: "/data",
- Value: map[string][]byte{sanitizeKey(id): bs},
+ Op: "add",
+ Path: "/data",
+ Value: func(m map[string][]byte) map[string][]byte {
+ d := make(map[string][]byte, len(m))
+ for key, val := range m {
+ d[sanitizeKey(key)] = val
+ }
+ return d
+ }(data),
},
}
- 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)
+ // If the Secret has data, patch it with the new data.
+ } else {
+ for key, val := range data {
+ m = append(m, kubeclient.JSONPatch{
+ Op: "add",
+ Path: "/data/" + sanitizeKey(key),
+ Value: val,
+ })
}
- return nil
- }
- m := []kubeclient.JSONPatch{
- {
- Op: "add",
- Path: "/data/" + sanitizeKey(id),
- Value: bs,
- },
}
- if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
- return fmt.Errorf("error patching Secret %s with /data/%s field: %v", s.secretName, sanitizeKey(id), err)
+ if err := s.client.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, m); err != nil {
+ return fmt.Errorf("error patching Secret %s: %w", secretName, err)
}
return nil
}
- secret.Data[sanitizeKey(id)] = bs
+ // No patch permissions, use UPDATE instead.
+ for key, val := range data {
+ mak.Set(&secret.Data, sanitizeKey(key), val)
+ }
if err := s.client.UpdateSecret(ctx, secret); err != nil {
- return err
+ return fmt.Errorf("error updating Secret %s: %w", s.secretName, err)
}
- return err
+ return nil
}
func (s *Store) loadState() (err error) {
@@ -172,9 +318,100 @@ func (s *Store) loadState() (err error) {
return nil
}
-func sanitizeKey(k ipn.StateKey) string {
- // The only valid characters in a Kubernetes secret key are alphanumeric, -,
- // _, and .
+// runCertReload relists and reloads all TLS certs for endpoints shared by this
+// node from Secrets other than the state Secret to ensure that renewed certs get eventually loaded.
+// It is not critical to reload a cert immediately after
+// renewal, so a daily check is acceptable.
+// Currently (3/2025) this is only used for the shared HA Ingress certs on 'read' replicas.
+// Note that if shared certs are not found in memory on an HTTPS request, we
+// do a Secret lookup, so this mechanism does not need to ensure that newly
+// added Ingresses' certs get loaded.
+func (s *Store) runCertReload(ctx context.Context, logf logger.Logf) {
+ ticker := time.NewTicker(time.Hour * 24)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ sel := s.certSecretSelector()
+ if err := s.loadCerts(ctx, sel); err != nil {
+ logf("[unexpected] error reloading TLS certs: %v", err)
+ }
+ }
+ }
+}
+
+// loadCerts lists all Secrets matching the provided selector and loads TLS
+// certs and keys from those.
+func (s *Store) loadCerts(ctx context.Context, sel map[string]string) error {
+ ss, err := s.client.ListSecrets(ctx, sel)
+ if err != nil {
+ return fmt.Errorf("error listing TLS Secrets: %w", err)
+ }
+ for _, secret := range ss.Items {
+ if !hasTLSData(&secret) {
+ continue
+ }
+ // Only load secrets that have valid domain names (ending in .ts.net)
+ if !strings.HasSuffix(secret.Name, ".ts.net") {
+ continue
+ }
+ s.memory.WriteState(ipn.StateKey(secret.Name)+".crt", secret.Data[keyTLSCert])
+ s.memory.WriteState(ipn.StateKey(secret.Name)+".key", secret.Data[keyTLSKey])
+ }
+ return nil
+}
+
+// canCreateSecret returns true if this node should be allowed to create the given
+// Secret in its namespace.
+func (s *Store) canCreateSecret(secret string) bool {
+ // Only allow creating the state Secret (and not TLS Secrets).
+ return secret == s.secretName
+}
+
+// canPatchSecret returns true if this node should be allowed to patch the given
+// Secret.
+func (s *Store) canPatchSecret(secret string) bool {
+ // For backwards compatibility reasons, setups where the proxies are not
+ // given PATCH permissions for state Secrets are allowed. For TLS
+ // Secrets, we should always have PATCH permissions.
+ if secret == s.secretName {
+ return s.canPatch
+ }
+ return true
+}
+
+// certSecretSelector returns a label selector that can be used to list all
+// Secrets that aren't Tailscale state Secrets and contain TLS certificates for
+// HTTPS endpoints that this node serves.
+// Currently (3/2025) this only applies to the Kubernetes Operator's ingress
+// ProxyGroup.
+func (s *Store) certSecretSelector() map[string]string {
+ if s.podName == "" {
+ return map[string]string{}
+ }
+ p := strings.LastIndex(s.podName, "-")
+ if p == -1 {
+ return map[string]string{}
+ }
+ pgName := s.podName[:p]
+ return map[string]string{
+ kubetypes.LabelSecretType: "certs",
+ kubetypes.LabelManaged: "true",
+ "tailscale.com/proxy-group": pgName,
+ }
+}
+
+// hasTLSData returns true if the provided Secret contains non-empty TLS cert and key.
+func hasTLSData(s *kubeapi.Secret) bool {
+ return len(s.Data[keyTLSCert]) != 0 && len(s.Data[keyTLSKey]) != 0
+}
+
+// sanitizeKey converts any value that can be converted to a string into a valid Kubernetes Secret key.
+// Valid characters are alphanumeric, -, _, and .
+// https://kubernetes.io/docs/concepts/configuration/secret/#restriction-names-data.
+func sanitizeKey[T ~string](k T) string {
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
return r
diff --git a/ipn/store/kubestore/store_kube_test.go b/ipn/store/kubestore/store_kube_test.go
new file mode 100644
index 000000000..2ed16e77b
--- /dev/null
+++ b/ipn/store/kubestore/store_kube_test.go
@@ -0,0 +1,723 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package kubestore
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "tailscale.com/envknob"
+ "tailscale.com/ipn"
+ "tailscale.com/ipn/store/mem"
+ "tailscale.com/kube/kubeapi"
+ "tailscale.com/kube/kubeclient"
+)
+
+func TestWriteState(t *testing.T) {
+ tests := []struct {
+ name string
+ initial map[string][]byte
+ key ipn.StateKey
+ value []byte
+ wantData map[string][]byte
+ allowPatch bool
+ }{
+ {
+ name: "basic_write",
+ initial: map[string][]byte{
+ "existing": []byte("old"),
+ },
+ key: "foo",
+ value: []byte("bar"),
+ wantData: map[string][]byte{
+ "existing": []byte("old"),
+ "foo": []byte("bar"),
+ },
+ allowPatch: true,
+ },
+ {
+ name: "update_existing",
+ initial: map[string][]byte{
+ "foo": []byte("old"),
+ },
+ key: "foo",
+ value: []byte("new"),
+ wantData: map[string][]byte{
+ "foo": []byte("new"),
+ },
+ allowPatch: true,
+ },
+ {
+ name: "create_new_secret",
+ key: "foo",
+ value: []byte("bar"),
+ wantData: map[string][]byte{
+ "foo": []byte("bar"),
+ },
+ allowPatch: true,
+ },
+ {
+ name: "patch_denied",
+ initial: map[string][]byte{
+ "foo": []byte("old"),
+ },
+ key: "foo",
+ value: []byte("new"),
+ wantData: map[string][]byte{
+ "foo": []byte("new"),
+ },
+ allowPatch: false,
+ },
+ {
+ name: "sanitize_key",
+ initial: map[string][]byte{
+ "clean-key": []byte("old"),
+ },
+ key: "dirty@key",
+ value: []byte("new"),
+ wantData: map[string][]byte{
+ "clean-key": []byte("old"),
+ "dirty_key": []byte("new"),
+ },
+ allowPatch: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ secret := tt.initial // track current state
+ client := &kubeclient.FakeClient{
+ GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
+ if secret == nil {
+ return nil, &kubeapi.Status{Code: 404}
+ }
+ return &kubeapi.Secret{Data: secret}, nil
+ },
+ CheckSecretPermissionsImpl: func(ctx context.Context, name string) (bool, bool, error) {
+ return tt.allowPatch, true, nil
+ },
+ CreateSecretImpl: func(ctx context.Context, s *kubeapi.Secret) error {
+ secret = s.Data
+ return nil
+ },
+ UpdateSecretImpl: func(ctx context.Context, s *kubeapi.Secret) error {
+ secret = s.Data
+ return nil
+ },
+ JSONPatchResourceImpl: func(ctx context.Context, name, resourceType string, patches []kubeclient.JSONPatch) error {
+ if !tt.allowPatch {
+ return &kubeapi.Status{Reason: "Forbidden"}
+ }
+ if secret == nil {
+ secret = make(map[string][]byte)
+ }
+ for _, p := range patches {
+ if p.Op == "add" && p.Path == "/data" {
+ secret = p.Value.(map[string][]byte)
+ } else if p.Op == "add" && strings.HasPrefix(p.Path, "/data/") {
+ key := strings.TrimPrefix(p.Path, "/data/")
+ secret[key] = p.Value.([]byte)
+ }
+ }
+ return nil
+ },
+ }
+
+ s := &Store{
+ client: client,
+ canPatch: tt.allowPatch,
+ secretName: "ts-state",
+ memory: mem.Store{},
+ }
+
+ err := s.WriteState(tt.key, tt.value)
+ if err != nil {
+ t.Errorf("WriteState() error = %v", err)
+ return
+ }
+
+ // Verify secret data
+ if diff := cmp.Diff(secret, tt.wantData); diff != "" {
+ t.Errorf("secret data mismatch (-got +want):\n%s", diff)
+ }
+
+ // Verify memory store was updated
+ got, err := s.memory.ReadState(ipn.StateKey(sanitizeKey(string(tt.key))))
+ if err != nil {
+ t.Errorf("reading from memory store: %v", err)
+ }
+ if !cmp.Equal(got, tt.value) {
+ t.Errorf("memory store key %q = %v, want %v", tt.key, got, tt.value)
+ }
+ })
+ }
+}
+
+func TestWriteTLSCertAndKey(t *testing.T) {
+ const (
+ testDomain = "my-app.tailnetxyz.ts.net"
+ testCert = "fake-cert"
+ testKey = "fake-key"
+ )
+
+ tests := []struct {
+ name string
+ initial map[string][]byte // pre-existing cert and key
+ certShareMode string
+ allowPatch bool // whether client can patch the Secret
+ wantSecretName string // name of the Secret where cert and key should be written
+ wantSecretData map[string][]byte
+ wantMemoryStore map[ipn.StateKey][]byte
+ }{
+ {
+ name: "basic_write",
+ initial: map[string][]byte{
+ "existing": []byte("old"),
+ },
+ allowPatch: true,
+ wantSecretName: "ts-state",
+ wantSecretData: map[string][]byte{
+ "existing": []byte("old"),
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ wantMemoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ },
+ {
+ name: "cert_share_mode_write",
+ certShareMode: "rw",
+ allowPatch: true,
+ wantSecretName: "my-app.tailnetxyz.ts.net",
+ wantSecretData: map[string][]byte{
+ "tls.crt": []byte(testCert),
+ "tls.key": []byte(testKey),
+ },
+ wantMemoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ },
+ {
+ name: "cert_share_mode_write_update_existing",
+ initial: map[string][]byte{
+ "tls.crt": []byte("old-cert"),
+ "tls.key": []byte("old-key"),
+ },
+ certShareMode: "rw",
+ allowPatch: true,
+ wantSecretName: "my-app.tailnetxyz.ts.net",
+ wantSecretData: map[string][]byte{
+ "tls.crt": []byte(testCert),
+ "tls.key": []byte(testKey),
+ },
+ wantMemoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ },
+ {
+ name: "update_existing",
+ initial: map[string][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte("old-cert"),
+ "my-app.tailnetxyz.ts.net.key": []byte("old-key"),
+ },
+ certShareMode: "",
+ allowPatch: true,
+ wantSecretName: "ts-state",
+ wantSecretData: map[string][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ wantMemoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ },
+ {
+ name: "patch_denied",
+ certShareMode: "",
+ allowPatch: false,
+ wantSecretName: "ts-state",
+ wantSecretData: map[string][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ wantMemoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+
+ // Set POD_NAME for testing selectors
+ envknob.Setenv("POD_NAME", "ingress-proxies-1")
+ defer envknob.Setenv("POD_NAME", "")
+
+ secret := tt.initial // track current state
+ client := &kubeclient.FakeClient{
+ GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
+ if secret == nil {
+ return nil, &kubeapi.Status{Code: 404}
+ }
+ return &kubeapi.Secret{Data: secret}, nil
+ },
+ CheckSecretPermissionsImpl: func(ctx context.Context, name string) (bool, bool, error) {
+ return tt.allowPatch, true, nil
+ },
+ CreateSecretImpl: func(ctx context.Context, s *kubeapi.Secret) error {
+ if s.Name != tt.wantSecretName {
+ t.Errorf("CreateSecret called with wrong name, got %q, want %q", s.Name, tt.wantSecretName)
+ }
+ secret = s.Data
+ return nil
+ },
+ UpdateSecretImpl: func(ctx context.Context, s *kubeapi.Secret) error {
+ if s.Name != tt.wantSecretName {
+ t.Errorf("UpdateSecret called with wrong name, got %q, want %q", s.Name, tt.wantSecretName)
+ }
+ secret = s.Data
+ return nil
+ },
+ JSONPatchResourceImpl: func(ctx context.Context, name, resourceType string, patches []kubeclient.JSONPatch) error {
+ if !tt.allowPatch {
+ return &kubeapi.Status{Reason: "Forbidden"}
+ }
+ if name != tt.wantSecretName {
+ t.Errorf("JSONPatchResource called with wrong name, got %q, want %q", name, tt.wantSecretName)
+ }
+ if secret == nil {
+ secret = make(map[string][]byte)
+ }
+ for _, p := range patches {
+ if p.Op == "add" && p.Path == "/data" {
+ secret = p.Value.(map[string][]byte)
+ } else if p.Op == "add" && strings.HasPrefix(p.Path, "/data/") {
+ key := strings.TrimPrefix(p.Path, "/data/")
+ secret[key] = p.Value.([]byte)
+ }
+ }
+ return nil
+ },
+ }
+
+ s := &Store{
+ client: client,
+ canPatch: tt.allowPatch,
+ secretName: tt.wantSecretName,
+ certShareMode: tt.certShareMode,
+ memory: mem.Store{},
+ }
+
+ err := s.WriteTLSCertAndKey(testDomain, []byte(testCert), []byte(testKey))
+ if err != nil {
+ t.Errorf("WriteTLSCertAndKey() error = '%v'", err)
+ return
+ }
+
+ // Verify secret data
+ if diff := cmp.Diff(secret, tt.wantSecretData); diff != "" {
+ t.Errorf("secret data mismatch (-got +want):\n%s", diff)
+ }
+
+ // Verify memory store was updated
+ for key, want := range tt.wantMemoryStore {
+ got, err := s.memory.ReadState(key)
+ if err != nil {
+ t.Errorf("reading from memory store: %v", err)
+ continue
+ }
+ if !cmp.Equal(got, want) {
+ t.Errorf("memory store key %q = %v, want %v", key, got, want)
+ }
+ }
+ })
+ }
+}
+
+func TestReadTLSCertAndKey(t *testing.T) {
+ const (
+ testDomain = "my-app.tailnetxyz.ts.net"
+ testCert = "fake-cert"
+ testKey = "fake-key"
+ )
+
+ tests := []struct {
+ name string
+ memoryStore map[ipn.StateKey][]byte // pre-existing memory store state
+ certShareMode string
+ domain string
+ secretData map[string][]byte // data to return from mock GetSecret
+ secretGetErr error // error to return from mock GetSecret
+ wantCert []byte
+ wantKey []byte
+ wantErr error
+ // what should end up in memory store after the store is created
+ wantMemoryStore map[ipn.StateKey][]byte
+ }{
+ {
+ name: "found",
+ memoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ domain: testDomain,
+ wantCert: []byte(testCert),
+ wantKey: []byte(testKey),
+ wantMemoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ },
+ {
+ name: "not_found",
+ domain: testDomain,
+ wantErr: ipn.ErrStateNotExist,
+ },
+ {
+ name: "cert_share_ro_mode_found_in_secret",
+ certShareMode: "ro",
+ domain: testDomain,
+ secretData: map[string][]byte{
+ "tls.crt": []byte(testCert),
+ "tls.key": []byte(testKey),
+ },
+ wantCert: []byte(testCert),
+ wantKey: []byte(testKey),
+ wantMemoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ },
+ {
+ name: "cert_share_ro_mode_found_in_memory",
+ certShareMode: "ro",
+ memoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ domain: testDomain,
+ wantCert: []byte(testCert),
+ wantKey: []byte(testKey),
+ wantMemoryStore: map[ipn.StateKey][]byte{
+ "my-app.tailnetxyz.ts.net.crt": []byte(testCert),
+ "my-app.tailnetxyz.ts.net.key": []byte(testKey),
+ },
+ },
+ {
+ name: "cert_share_ro_mode_not_found",
+ certShareMode: "ro",
+ domain: testDomain,
+ secretGetErr: &kubeapi.Status{Code: 404},
+ wantErr: ipn.ErrStateNotExist,
+ },
+ {
+ name: "cert_share_ro_mode_empty_cert_in_secret",
+ certShareMode: "ro",
+ domain: testDomain,
+ secretData: map[string][]byte{
+ "tls.crt": {},
+ "tls.key": []byte(testKey),
+ },
+ wantErr: ipn.ErrStateNotExist,
+ },
+ {
+ name: "cert_share_ro_mode_kube_api_error",
+ certShareMode: "ro",
+ domain: testDomain,
+ secretGetErr: fmt.Errorf("api error"),
+ wantErr: fmt.Errorf("getting TLS Secret %q: api error", sanitizeKey(testDomain)),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+
+ client := &kubeclient.FakeClient{
+ GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
+ if tt.secretGetErr != nil {
+ return nil, tt.secretGetErr
+ }
+ return &kubeapi.Secret{Data: tt.secretData}, nil
+ },
+ }
+
+ s := &Store{
+ client: client,
+ secretName: "ts-state",
+ certShareMode: tt.certShareMode,
+ memory: mem.Store{},
+ }
+
+ // Initialize memory store
+ for k, v := range tt.memoryStore {
+ s.memory.WriteState(k, v)
+ }
+
+ gotCert, gotKey, err := s.ReadTLSCertAndKey(tt.domain)
+ if tt.wantErr != nil {
+ if err == nil {
+ t.Errorf("ReadTLSCertAndKey() error = nil, want error containing %v", tt.wantErr)
+ return
+ }
+ if !strings.Contains(err.Error(), tt.wantErr.Error()) {
+ t.Errorf("ReadTLSCertAndKey() error = %v, want error containing %v", err, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Errorf("ReadTLSCertAndKey() unexpected error: %v", err)
+ return
+ }
+
+ if !bytes.Equal(gotCert, tt.wantCert) {
+ t.Errorf("ReadTLSCertAndKey() gotCert = %v, want %v", gotCert, tt.wantCert)
+ }
+ if !bytes.Equal(gotKey, tt.wantKey) {
+ t.Errorf("ReadTLSCertAndKey() gotKey = %v, want %v", gotKey, tt.wantKey)
+ }
+
+ // Verify memory store contents after operation
+ if tt.wantMemoryStore != nil {
+ for key, want := range tt.wantMemoryStore {
+ got, err := s.memory.ReadState(key)
+ if err != nil {
+ t.Errorf("reading from memory store: %v", err)
+ continue
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("memory store key %q = %v, want %v", key, got, want)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestNewWithClient(t *testing.T) {
+ const (
+ secretName = "ts-state"
+ testCert = "fake-cert"
+ testKey = "fake-key"
+ )
+
+ certSecretsLabels := map[string]string{
+ "tailscale.com/secret-type": "certs",
+ "tailscale.com/managed": "true",
+ "tailscale.com/proxy-group": "ingress-proxies",
+ }
+
+ // Helper function to create Secret objects for testing
+ makeSecret := func(name string, labels map[string]string, certSuffix string) kubeapi.Secret {
+ return kubeapi.Secret{
+ ObjectMeta: kubeapi.ObjectMeta{
+ Name: name,
+ Labels: labels,
+ },
+ Data: map[string][]byte{
+ "tls.crt": []byte(testCert + certSuffix),
+ "tls.key": []byte(testKey + certSuffix),
+ },
+ }
+ }
+
+ tests := []struct {
+ name string
+ stateSecretContents map[string][]byte // data in state Secret
+ TLSSecrets []kubeapi.Secret // list of TLS cert Secrets
+ certMode string
+ secretGetErr error // error to return from GetSecret
+ secretsListErr error // error to return from ListSecrets
+ wantMemoryStoreContents map[ipn.StateKey][]byte
+ wantErr error
+ }{
+ {
+ name: "empty_state_secret",
+ stateSecretContents: map[string][]byte{},
+ wantMemoryStoreContents: map[ipn.StateKey][]byte{},
+ },
+ {
+ name: "state_secret_not_found",
+ secretGetErr: &kubeapi.Status{Code: 404},
+ wantMemoryStoreContents: map[ipn.StateKey][]byte{},
+ },
+ {
+ name: "state_secret_get_error",
+ secretGetErr: fmt.Errorf("some error"),
+ wantErr: fmt.Errorf("error loading state from kube Secret: some error"),
+ },
+ {
+ name: "load_existing_state",
+ stateSecretContents: map[string][]byte{
+ "foo": []byte("bar"),
+ "baz": []byte("qux"),
+ },
+ wantMemoryStoreContents: map[ipn.StateKey][]byte{
+ "foo": []byte("bar"),
+ "baz": []byte("qux"),
+ },
+ },
+ {
+ name: "load_select_certs_in_read_only_mode",
+ certMode: "ro",
+ stateSecretContents: map[string][]byte{
+ "foo": []byte("bar"),
+ },
+ TLSSecrets: []kubeapi.Secret{
+ makeSecret("app1.tailnetxyz.ts.net", certSecretsLabels, "1"),
+ makeSecret("app2.tailnetxyz.ts.net", certSecretsLabels, "2"),
+ makeSecret("some-other-secret", nil, "3"),
+ makeSecret("app3.other-proxies.ts.net", map[string]string{
+ "tailscale.com/secret-type": "certs",
+ "tailscale.com/managed": "true",
+ "tailscale.com/proxy-group": "some-other-proxygroup",
+ }, "4"),
+ },
+ wantMemoryStoreContents: map[ipn.StateKey][]byte{
+ "foo": []byte("bar"),
+ "app1.tailnetxyz.ts.net.crt": []byte(testCert + "1"),
+ "app1.tailnetxyz.ts.net.key": []byte(testKey + "1"),
+ "app2.tailnetxyz.ts.net.crt": []byte(testCert + "2"),
+ "app2.tailnetxyz.ts.net.key": []byte(testKey + "2"),
+ },
+ },
+ {
+ name: "load_select_certs_in_read_write_mode",
+ certMode: "rw",
+ stateSecretContents: map[string][]byte{
+ "foo": []byte("bar"),
+ },
+ TLSSecrets: []kubeapi.Secret{
+ makeSecret("app1.tailnetxyz.ts.net", certSecretsLabels, "1"),
+ makeSecret("app2.tailnetxyz.ts.net", certSecretsLabels, "2"),
+ makeSecret("some-other-secret", nil, "3"),
+ makeSecret("app3.other-proxies.ts.net", map[string]string{
+ "tailscale.com/secret-type": "certs",
+ "tailscale.com/managed": "true",
+ "tailscale.com/proxy-group": "some-other-proxygroup",
+ }, "4"),
+ },
+ wantMemoryStoreContents: map[ipn.StateKey][]byte{
+ "foo": []byte("bar"),
+ "app1.tailnetxyz.ts.net.crt": []byte(testCert + "1"),
+ "app1.tailnetxyz.ts.net.key": []byte(testKey + "1"),
+ "app2.tailnetxyz.ts.net.crt": []byte(testCert + "2"),
+ "app2.tailnetxyz.ts.net.key": []byte(testKey + "2"),
+ },
+ },
+ {
+ name: "list_cert_secrets_fails",
+ certMode: "ro",
+ stateSecretContents: map[string][]byte{
+ "foo": []byte("bar"),
+ },
+ secretsListErr: fmt.Errorf("list error"),
+ // The error is logged but not returned, and state is still loaded
+ wantMemoryStoreContents: map[ipn.StateKey][]byte{
+ "foo": []byte("bar"),
+ },
+ },
+ {
+ name: "cert_secrets_not_loaded_when_not_in_share_mode",
+ certMode: "",
+ stateSecretContents: map[string][]byte{
+ "foo": []byte("bar"),
+ },
+ TLSSecrets: []kubeapi.Secret{
+ makeSecret("app1.tailnetxyz.ts.net", certSecretsLabels, "1"),
+ },
+ wantMemoryStoreContents: map[ipn.StateKey][]byte{
+ "foo": []byte("bar"),
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ envknob.Setenv("TS_CERT_SHARE_MODE", tt.certMode)
+
+ t.Setenv("POD_NAME", "ingress-proxies-1")
+
+ client := &kubeclient.FakeClient{
+ GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
+ if tt.secretGetErr != nil {
+ return nil, tt.secretGetErr
+ }
+ if name == secretName {
+ return &kubeapi.Secret{Data: tt.stateSecretContents}, nil
+ }
+ return nil, &kubeapi.Status{Code: 404}
+ },
+ CheckSecretPermissionsImpl: func(ctx context.Context, name string) (bool, bool, error) {
+ return true, true, nil
+ },
+ ListSecretsImpl: func(ctx context.Context, selector map[string]string) (*kubeapi.SecretList, error) {
+ if tt.secretsListErr != nil {
+ return nil, tt.secretsListErr
+ }
+ var matchingSecrets []kubeapi.Secret
+ for _, secret := range tt.TLSSecrets {
+ matches := true
+ for k, v := range selector {
+ if secret.Labels[k] != v {
+ matches = false
+ break
+ }
+ }
+ if matches {
+ matchingSecrets = append(matchingSecrets, secret)
+ }
+ }
+ return &kubeapi.SecretList{Items: matchingSecrets}, nil
+ },
+ }
+
+ s, err := newWithClient(t.Logf, client, secretName)
+ if tt.wantErr != nil {
+ if err == nil {
+ t.Errorf("NewWithClient() error = nil, want error containing %v", tt.wantErr)
+ return
+ }
+ if !strings.Contains(err.Error(), tt.wantErr.Error()) {
+ t.Errorf("NewWithClient() error = %v, want error containing %v", err, tt.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("NewWithClient() unexpected error: %v", err)
+ return
+ }
+
+ // Verify memory store contents
+ gotJSON, err := s.memory.ExportToJSON()
+ if err != nil {
+ t.Errorf("ExportToJSON failed: %v", err)
+ return
+ }
+ var got map[ipn.StateKey][]byte
+ if err := json.Unmarshal(gotJSON, &got); err != nil {
+ t.Errorf("failed to unmarshal memory store JSON: %v", err)
+ return
+ }
+ want := tt.wantMemoryStoreContents
+ if want == nil {
+ want = map[ipn.StateKey][]byte{}
+ }
+ if diff := cmp.Diff(got, want); diff != "" {
+ t.Errorf("memory store contents mismatch (-got +want):\n%s", diff)
+ }
+ })
+ }
+}
diff --git a/ipn/store/store_aws.go b/ipn/store/store_aws.go
index e164f9de7..d39e84319 100644
--- a/ipn/store/store_aws.go
+++ b/ipn/store/store_aws.go
@@ -6,7 +6,9 @@
package store
import (
+ "tailscale.com/ipn"
"tailscale.com/ipn/store/awsstore"
+ "tailscale.com/types/logger"
)
func init() {
@@ -14,5 +16,11 @@ func init() {
}
func registerAWSStore() {
- Register("arn:", awsstore.New)
+ Register("arn:", func(logf logger.Logf, arg string) (ipn.StateStore, error) {
+ ssmARN, opts, err := awsstore.ParseARNAndOpts(arg)
+ if err != nil {
+ return nil, err
+ }
+ return awsstore.New(logf, ssmARN, opts...)
+ })
}