Files
tailscale/ipn/store/kubestore/store_kube.go
T
chaosinthecrd 97a75c837d cmd/k8s-operator,ipn/store/kubestore,kube/kubetypes: share ACME account key per tailnet
Introduce a per-tailnet shared ACME account key so that all ingress
ProxyGroup replicas on a tailnet present the same account identity to
Let's Encrypt. This lets renewals claim the ARI "replaces" exemption
from the 50-certs-per-week rate limit, surviving Pod restarts,
ProxyGroup recreation, and cluster migrations.

The operator provisions a "tailscale-acme-accounts" Secret in its
namespace, guarded by a finalizer and a deletion warning event, and
watched so it is recreated promptly if removed. Proxies migrate any
pre-existing per-pod key into the shared Secret on first boot, adopt
the shared key on subsequent boots, and restore it on cert writes if
the Secret was recreated empty. Certs are stamped with the fingerprint
of the issuing account so renewals skip the "replaces" claim when the
account doesn't match.

Opt-in per-ProxyGroup via the tailscale.com/share-acme-account
annotation, or operator-wide via OPERATOR_SHARED_ACME_ACCOUNT_KEY.

Updates #18251
Updates #20288

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
2026-07-27 12:06:41 +01:00

725 lines
26 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package kubestore contains an ipn.StateStore implementation using Kubernetes Secrets.
package kubestore
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/store"
"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"
)
func init() {
store.Register("kube:", func(logf logger.Logf, path string) (ipn.StateStore, error) {
secretName := strings.TrimPrefix(path, "kube:")
return New(logf, secretName)
})
}
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"
keyTLSCert = "tls.crt"
keyTLSKey = "tls.key"
// keyACMEAcctFP is the cert Secret field that records the SHA-256
// fingerprint of the PEM-encoded ACME account key that issued the
// cert. The renewal path uses this to decide whether to include the
// ARI "replaces" hint: only if the current account key matches
// (otherwise Let's Encrypt rejects the claim).
keyACMEAcctFP = "acme-account-fingerprint"
// acmeAccountStateKey is the ipn.StateStore key under which tailscaled
// stores its ACME account private key. Mirrors the acmePEMName constant
// in ipn/ipnlocal/cert.go. Duplicated here to avoid an import cycle.
acmeAccountStateKey = "acme-account.key.pem"
)
// Store is an ipn.StateStore that uses a Kubernetes Secret for persistence.
type Store struct {
client kubeclient.Client
canPatch bool
secretName string // state Secret
certShareMode string // 'ro', 'rw', or empty
podName string
// acmeAccountsSecretName, when non-empty in "rw" cert share mode,
// routes reads and writes of acmeAccountStateKey to acmeAccountField
// inside this shared per-tailnet Secret. See #18251.
acmeAccountsSecretName string
acmeAccountField string
// preAdoptedLocalKey is the SHA-256 of the per-pod ACME account key
// that was in the local state Secret before we adopted a foreign
// shared key. Non-nil only when adoption changed the key.
preAdoptedLocalKey []byte
logf logger.Logf
// 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 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
}
if os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
// 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
}
s := &Store{
client: c,
canPatch: canPatch,
secretName: secretName,
podName: os.Getenv("POD_NAME"),
logf: logf,
}
if envknob.IsCertShareReadWriteMode() {
s.certShareMode = "rw"
} else if envknob.IsCertShareReadOnlyMode() {
s.certShareMode = "ro"
}
// Configure shared ACME account lookup. Only meaningful for the cert
// issuer (cert share "rw") — read replicas never issue.
if s.certShareMode == "rw" {
s.acmeAccountsSecretName = os.Getenv("TS_ACME_ACCOUNT_SECRET_NAME")
s.acmeAccountField = os.Getenv("TS_ACME_ACCOUNT_FIELD")
if s.acmeAccountsSecretName != "" && s.acmeAccountField == "" {
s.logf("[unexpected] TS_ACME_ACCOUNT_SECRET_NAME set without TS_ACME_ACCOUNT_FIELD; ignoring shared ACME account configuration")
s.acmeAccountsSecretName = ""
}
}
// 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 read-only cert share mode, pre-load existing shared certs.
// Write replicas never load certs in-memory to avoid a situation where,
// after Ingress recreation (and the associated cert Secret recreation), new
// TLS certs don't get issued because the write replica still has certs
// in-memory. Instead, write replicas fetch certs from Secret on each request.
if 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.
s.logf("[unexpected] error loading TLS certs: %v", err)
}
}
if s.certShareMode == "ro" {
go s.runCertReload(context.Background())
}
if s.acmeAccountsSecretName != "" {
if err := s.reconcileSharedACMEAccountKey(); err != nil {
// Non-fatal: the cert loop will retry on next issuance.
s.logf("kubestore: reconciling shared ACME account key: %v", err)
}
}
return s, nil
}
// reconcileSharedACMEAccountKey aligns the in-memory ACME account key with
// the shared per-tailnet field: adopt the shared value if present, otherwise
// copy the local per-pod key up so upgrading deployments keep renewal
// continuity.
func (s *Store) reconcileSharedACMEAccountKey() error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
sharedSecret, err := s.client.GetSecret(ctx, s.acmeAccountsSecretName)
if err != nil && !kubeclient.IsNotFoundErr(err) {
return fmt.Errorf("reading shared ACME accounts Secret %q: %w", s.acmeAccountsSecretName, err)
}
var sharedKey []byte
if sharedSecret != nil {
sharedKey = sharedSecret.Data[sanitizeKey(s.acmeAccountField)]
}
if len(sharedKey) > 0 {
// Shared field already populated for this tailnet. Adopt it. If
// our local per-pod key differs, remember its fingerprint so
// legacy certs on this pod (issued before we started stamping
// fingerprints) can be recognised as mis-aligned on renewal.
localKey, err := s.memory.ReadState(ipn.StateKey(acmeAccountStateKey))
if err == nil && len(localKey) > 0 && !bytes.Equal(localKey, sharedKey) {
sum := sha256.Sum256(localKey)
s.preAdoptedLocalKey = sum[:]
}
s.memory.WriteState(ipn.StateKey(acmeAccountStateKey), sharedKey)
return nil
}
// Shared field is empty. If we have a per-pod key from the state
// Secret, copy it up so existing renewals stay exempt.
localKey, err := s.memory.ReadState(ipn.StateKey(acmeAccountStateKey))
if err != nil || len(localKey) == 0 {
// Nothing local either; the cert loop will generate one on first
// use and route the write through writeSharedACMEAccountKey.
return nil
}
if err := s.writeSharedACMEAccountKey(localKey); err != nil {
return fmt.Errorf("copying per-pod ACME account key to shared Secret: %w", err)
}
s.logf("kubestore: migrated per-pod ACME account key into shared Secret %q field %q", s.acmeAccountsSecretName, s.acmeAccountField)
return nil
}
// writeSharedACMEAccountKey writes key to acmeAccountField inside
// acmeAccountsSecretName, using whichever access pattern (patch or update)
// this Store has permission for.
func (s *Store) writeSharedACMEAccountKey(key []byte) error {
return s.updateSecret(map[string][]byte{s.acmeAccountField: key}, s.acmeAccountsSecretName)
}
// maybeRestoreSharedACMEAccountKey writes the in-memory ACME account key to
// the shared Secret's per-tailnet field if that field is empty or missing,
// e.g. because the Secret was deleted and recreated while this process was
// running. It never overwrites an existing shared key.
func (s *Store) maybeRestoreSharedACMEAccountKey() error {
if s.acmeAccountsSecretName == "" {
return nil
}
key, err := s.memory.ReadState(ipn.StateKey(acmeAccountStateKey))
if err != nil || len(key) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
shared, err := s.client.GetSecret(ctx, s.acmeAccountsSecretName)
if err != nil && !kubeclient.IsNotFoundErr(err) {
return fmt.Errorf("reading shared ACME accounts Secret %q: %w", s.acmeAccountsSecretName, err)
}
if shared != nil && len(shared.Data[sanitizeKey(s.acmeAccountField)]) > 0 {
return nil
}
s.logf("kubestore: shared ACME accounts Secret %q field %q is empty; restoring account key from memory", s.acmeAccountsSecretName, s.acmeAccountField)
return s.writeSharedACMEAccountKey(key)
}
// acmeAccountKeyFingerprint returns the SHA-256 of the current in-memory
// PEM-encoded ACME account key, or (nil, false) if the key isn't set.
func acmeAccountKeyFingerprint(m *mem.Store) ([]byte, bool) {
key, err := m.ReadState(ipn.StateKey(acmeAccountStateKey))
if err != nil || len(key) == 0 {
return nil, false
}
sum := sha256.Sum256(key)
return sum[:], true
}
// ShouldUseARIReplacesForRenewal reports whether the current ACME account
// key matches the one that issued the cert for domain. See #18251.
func (s *Store) ShouldUseARIReplacesForRenewal(domain string) (bool, error) {
if s.certShareMode != "rw" {
return true, nil
}
curFP, ok := acmeAccountKeyFingerprint(&s.memory)
if !ok {
// No current account key in memory yet; nothing to compare.
return true, nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
sec, err := s.client.GetSecret(ctx, domain)
if err != nil {
if kubeclient.IsNotFoundErr(err) {
return true, nil
}
return true, fmt.Errorf("getting TLS Secret %q: %w", domain, err)
}
certFP := sec.Data[keyACMEAcctFP]
if len(certFP) == 0 {
// Legacy cert, no fingerprint stamp. Assume misaligned only if
// we adopted a foreign shared key.
if len(s.preAdoptedLocalKey) > 0 {
return false, nil
}
return true, nil
}
return bytes.Equal(certFP, curFP), nil
}
func (s *Store) SetDialer(d func(ctx context.Context, network, address string) (net.Conn, error)) {
s.client.SetDialer(d)
}
func (s *Store) String() string { return "kube.Store" }
// ReadState implements the StateStore interface.
func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
return s.memory.ReadState(ipn.StateKey(sanitizeKey(id)))
}
// WriteState implements the StateStore interface.
func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
defer func() {
if err == nil {
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
}
}()
if s.acmeAccountsSecretName != "" && string(id) == acmeAccountStateKey {
if bs == nil {
return s.removeSecretField(s.acmeAccountField, s.acmeAccountsSecretName)
}
return s.writeSharedACMEAccountKey(bs)
}
if bs == nil {
return s.removeSecretField(string(id), s.secretName)
}
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. In cert-share "rw" mode it
// also stamps acme-account-fingerprint alongside the cert so the renewal path
// can tell whether the current ACME account key issued this cert.
func (s *Store) WriteTLSCertAndKey(domain string, cert, key []byte) (err error) {
if s.certShareMode == "ro" {
s.logf("[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)
}
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,
}
if fp, ok := acmeAccountKeyFingerprint(&s.memory); ok {
data[keyACMEAcctFP] = fp
}
// The shared Secret may have been deleted and recreated empty
// while we were running; re-assert the account key if so.
if err := s.maybeRestoreSharedACMEAccountKey(); err != nil {
s.logf("kubestore: restoring shared ACME account key: %v", err)
}
}
if err := s.updateSecret(data, secretName); err != nil {
return fmt.Errorf("error writing TLS cert and key to Secret: %w", err)
}
// TODO(irbekrm): certs for write replicas are currently not
// written to memory to avoid out of sync memory state after
// Ingress resources have been recreated. This means that TLS
// certs for write replicas are retrieved from the Secret on
// each HTTPS request. This is a temporary solution till we
// implement a Secret watch.
if s.certShareMode != "rw" {
s.memory.WriteState(ipn.StateKey(domain+".crt"), cert)
s.memory.WriteState(ipn.StateKey(domain+".key"), key)
}
return nil
}
// 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.
// Note that write replicas of HA Ingress always retrieve TLS certs from Secrets.
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 == "" {
return nil, nil, ipn.ErrStateNotExist
}
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
}
st, ok := err.(*kubeapi.Status)
if ok && st.Code == http.StatusForbidden && (s.certShareMode == "ro" || s.certShareMode == "rw") {
// In cert share mode, we read from a dedicated Secret per domain.
// To get here, we already had a cache miss from our in-memory
// store. For write replicas, that means it wasn't available on
// start and it wasn't written since. For read replicas, that means
// it wasn't available on start and it hasn't been reloaded in the
// background. So getting a "forbidden" error is an expected
// "not found" case where we've been asked for a cert we don't
// expect to issue, and so the forbidden error reflects that the
// operator didn't assign permission for a Secret for that domain.
//
// This code path gets triggered by the admin UI's machine page,
// which queries for the node's own TLS cert existing via the
// "tls-cert-status" c2n API.
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.
//
// TODO(irbekrm): currently certs for write replicas of HA Ingress get
// retrieved from the cluster Secret on each HTTPS request to avoid a
// situation when after Ingress recreation stale certs are read from
// memory.
// Fix this by watching Secrets to ensure that memory store gets updated
// when Secrets are deleted.
if s.certShareMode == "ro" {
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 {
s.logf("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 {
s.logf("kubestore: error creating tailscaled state Event: %v", err)
}
}
cancel()
}()
secret, err := s.client.GetSecret(ctx, secretName)
if err != nil {
// If the Secret does not exist, create it with the required data.
if kubeclient.IsNotFoundErr(err) && s.canCreateSecret(secretName) {
return s.client.CreateSecret(ctx, &kubeapi.Secret{
TypeMeta: kubeapi.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: kubeapi.ObjectMeta{
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 fmt.Errorf("error getting Secret %s: %w", secretName, err)
}
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: 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 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,
})
}
}
if err := s.client.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, m); err != nil {
return fmt.Errorf("error patching Secret %s: %w", secretName, err)
}
return nil
}
// 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 fmt.Errorf("error updating Secret %s: %w", s.secretName, err)
}
return nil
}
func (s *Store) removeSecretField(key, secretName string) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if s.canPatchSecret(secretName) {
return s.client.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, []kubeclient.JSONPatch{
{
Op: "remove",
Path: "/data/" + sanitizeKey(ipn.StateKey(key)),
},
})
}
// No patch permissions, use UPDATE: get the secret, delete the key, update.
secret, err := s.client.GetSecret(ctx, secretName)
if err != nil {
return fmt.Errorf("error getting Secret %s: %w", secretName, err)
}
delete(secret.Data, sanitizeKey(ipn.StateKey(key)))
if err := s.client.UpdateSecret(ctx, secret); err != nil {
return fmt.Errorf("error updating Secret %s: %w", secretName, err)
}
return nil
}
func (s *Store) loadState() (err error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
secret, err := s.client.GetSecret(ctx, s.secretName)
if err != nil {
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 {
s.logf("kubestore: error creating Event: %v", err)
}
return err
}
if err := s.client.Event(ctx, eventTypeNormal, reasonTailscaleStateLoaded, "Successfully loaded tailscaled state from Secret"); err != nil {
s.logf("kubestore: error creating Event: %v", err)
}
data, err := s.maybeStripAttestationKeyFromProfile(secret.Data)
if err != nil {
return fmt.Errorf("error attempting to strip attestation data from state Secret: %w", err)
}
s.memory.LoadFromMap(data)
return nil
}
// maybeStripAttestationKeyFromProfile removes the hardware attestation key
// field from serialized Tailscale profile. This is done to recover from a bug
// introduced in 1.92, where node-bound hardware attestation keys were added to
// Tailscale states stored in Kubernetes Secrets.
// See https://github.com/tailscale/tailscale/issues/18302
// TODO(irbekrm): it would be good if we could somehow determine when we no
// longer need to run this check.
func (s *Store) maybeStripAttestationKeyFromProfile(data map[string][]byte) (map[string][]byte, error) {
prefsKey := extractPrefsKey(data)
prefsBytes, ok := data[prefsKey]
if !ok {
return data, nil
}
var prefs map[string]any
if err := json.Unmarshal(prefsBytes, &prefs); err != nil {
s.logf("[unexpected]: kube store: failed to unmarshal prefs data")
// don't error as in most cases the state won't have the attestation key
return data, nil
}
config, ok := prefs["Config"].(map[string]any)
if !ok {
return data, nil
}
if _, hasKey := config["AttestationKey"]; !hasKey {
return data, nil
}
s.logf("kube store: found redundant attestation key, deleting")
delete(config, "AttestationKey")
prefsBytes, err := json.Marshal(prefs)
if err != nil {
return nil, fmt.Errorf("[unexpected] kube store: failed to marshal profile after removing attestation key: %v", err)
}
data[prefsKey] = prefsBytes
if err := s.updateSecret(map[string][]byte{prefsKey: prefsBytes}, s.secretName); err != nil {
// don't error out - this might have been a temporary kube API server
// connection issue. The key will be removed from the in-memory cache
// and we'll retry updating the Secret on the next restart.
s.logf("kube store: error updating Secret after stripping AttestationKey: %v", err)
}
return data, nil
}
const currentProfileKey = "_current-profile"
// extractPrefs returns the key at which Tailscale prefs are stored in the
// provided Secret data.
func extractPrefsKey(data map[string][]byte) string {
return string(data[currentProfileKey])
}
// 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) {
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 {
s.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). The
// shared ACME accounts Secret is precreated by the operator, so write
// replicas never need create permission for it.
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 and the shared ACME accounts Secret, 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 (7/2025) this only applies to the Kubernetes Operator's ProxyGroup
// when spec.Type is "ingress" or "kube-apiserver".
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: kubetypes.LabelSecretTypeCerts,
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
}
return '_'
}, string(k))
}