summaryrefslogtreecommitdiffhomepage
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/containerboot/certs.go146
-rw-r--r--cmd/containerboot/main.go2
-rw-r--r--cmd/containerboot/serve.go24
-rw-r--r--cmd/containerboot/settings.go12
-rw-r--r--cmd/containerboot/tailscaled.go3
-rw-r--r--cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml2
-rw-r--r--cmd/k8s-operator/egress-pod-readiness.go6
-rw-r--r--cmd/k8s-operator/egress-services.go12
-rw-r--r--cmd/k8s-operator/ingress-for-pg.go153
-rw-r--r--cmd/k8s-operator/metrics_resources.go3
-rw-r--r--cmd/k8s-operator/operator.go24
-rw-r--r--cmd/k8s-operator/proxygroup_specs.go15
-rw-r--r--cmd/k8s-operator/sts.go4
-rw-r--r--cmd/k8s-operator/svc.go8
14 files changed, 372 insertions, 42 deletions
diff --git a/cmd/containerboot/certs.go b/cmd/containerboot/certs.go
new file mode 100644
index 000000000..7d0ddce90
--- /dev/null
+++ b/cmd/containerboot/certs.go
@@ -0,0 +1,146 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build linux
+
+package main
+
+import (
+ "context"
+ "log"
+ "net"
+ "sync"
+ "time"
+
+ "tailscale.com/ipn"
+ "tailscale.com/util/goroutines"
+)
+
+// TODO:
+// - add logic to stop all the goroutines (on SIGTERM)
+// - add unit tests
+// certManager is responsible for issuing certificates for known domains and for
+// maintaining a loop that re-attempts issuance daily.
+// Currently cert manager logic is only run on ingress ProxyGroup replicas that are responsible for managing certs for
+// HA Ingress HTTPS endpoints ('write' replicas).
+type certManager struct {
+ parentCtx context.Context
+ lc localClient
+ tracker goroutines.Tracker // tracks running goroutines
+ mu sync.Mutex // guards the following
+ // certLoops contains a map of DNS names, for which we currently need to
+ // manage certs to cancel functions that allow stopping a goroutine when
+ // we no longer need to manage certs for the DNS name.
+ certLoops map[string]context.CancelFunc
+}
+
+// ensureCertLoops ensures that, for all currently managed Service HTTPS
+// endpoints, there is a cert loop responsible for issuing and ensuring the
+// renewal of the TLS certs.
+func (cm *certManager) ensureCertLoops(ctx context.Context, sc *ipn.ServeConfig) error {
+ currentDomains := make(map[string]bool)
+ const httpsPort = "443"
+ for _, service := range sc.Services {
+ for hostPort := range service.Web {
+ domain, port, err := net.SplitHostPort(string(hostPort))
+ if err != nil {
+ log.Printf("[unexpected] unable to parse HostPort %s", hostPort)
+ continue
+ }
+ if port != httpsPort { // HA Ingress' HTTP endpoint
+ continue
+ }
+ currentDomains[domain] = true
+ }
+ }
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ for domain := range currentDomains {
+ if _, exists := cm.certLoops[domain]; !exists {
+ ctx, cancel := context.WithCancel(cm.parentCtx)
+ cm.certLoops[domain] = cancel
+ cm.tracker.Go(func() { cm.runCertLoop(ctx, domain) })
+ }
+ }
+
+ // Stop goroutines for domain names that are no longer in the config.
+ for domain, cancel := range cm.certLoops {
+ if !currentDomains[domain] {
+ cancel()
+ delete(cm.certLoops, domain)
+ }
+ }
+ return nil
+}
+
+// runCertLoop:
+// - calls localAPI certificate endpoint to ensure that certs are issued for the
+// given domain name
+// - calls localAPI certificate endpoint daily to ensure that certs are renewed
+// - if certificate issuance failed retries after an exponential backoff period
+// starting at 1 minute and capped at 24 hours. Reset the backoff once issuance succeeds.
+// Note that renewal check also happens when the node receives an HTTPS request and it is possible that certs get
+// renewed at that point. Renewal here is needed to prevent the shared certs from expiry in edge cases where the 'write'
+// replica does not get any HTTPS requests.
+// https://letsencrypt.org/docs/integration-guide/#retrying-failures
+func (cm *certManager) runCertLoop(ctx context.Context, domain string) {
+ const (
+ normalInterval = 24 * time.Hour // regular renewal check
+ initialRetry = 1 * time.Minute // initial backoff after a failure
+ maxRetryInterval = 24 * time.Hour // max backoff period
+ )
+ timer := time.NewTimer(0) // fire off timer immediately
+ defer timer.Stop()
+ retryCount := 0
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-timer.C:
+ // We call the certificate endpoint, but don't do anything
+ // with the returned certs here.
+ // The call to the certificate endpoint will ensure that
+ // certs are issued/renewed as needed and stored in the
+ // relevant state store. For example, for HA Ingress
+ // 'write' replica, the cert and key will be stored in a
+ // Kubernetes Secret named after the domain for which we
+ // are issuing.
+ // Note that renewals triggered by the call to the
+ // certificates endpoint here and by renewal check
+ // triggered during a call to node's HTTPS endpoint
+ // share the same state/renewal lock mechanism, so we
+ // should not run into redundant issuances during
+ // concurrent renewal checks.
+ // TODO(irbekrm): maybe it is worth adding a new
+ // issuance endpoint that explicitly only triggers
+ // issuance and stores certs in the relevant store, but
+ // does not return certs to the caller?
+ _, _, err := cm.lc.CertPair(ctx, domain)
+ if err != nil {
+ log.Printf("error refreshing certificate for %s: %v", domain, err)
+ }
+ var nextInterval time.Duration
+ // TODO(irbekrm): distinguish between LE rate limit
+ // errors and other error types like transient network
+ // errors.
+ if err == nil {
+ retryCount = 0
+ nextInterval = normalInterval
+ } else {
+ retryCount++
+ // Calculate backoff: initialRetry * 2^(retryCount-1)
+ // For retryCount=1: 1min * 2^0 = 1min
+ // For retryCount=2: 1min * 2^1 = 2min
+ // For retryCount=3: 1min * 2^2 = 4min
+ backoff := initialRetry * time.Duration(1<<(retryCount-1))
+ if backoff > maxRetryInterval {
+ backoff = maxRetryInterval
+ }
+ nextInterval = backoff
+ log.Printf("Error refreshing certificate for %s (retry %d): %v. Will retry in %v\n",
+ domain, retryCount, err, nextInterval)
+ }
+ timer.Reset(nextInterval)
+ }
+ }
+}
diff --git a/cmd/containerboot/main.go b/cmd/containerboot/main.go
index cf4bd8620..5f8052bb9 100644
--- a/cmd/containerboot/main.go
+++ b/cmd/containerboot/main.go
@@ -646,7 +646,7 @@ runLoop:
if cfg.ServeConfigPath != "" {
triggerWatchServeConfigChanges.Do(func() {
- go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client, kc)
+ go watchServeConfigChanges(ctx, certDomainChanged, certDomain, client, kc, cfg)
})
}
diff --git a/cmd/containerboot/serve.go b/cmd/containerboot/serve.go
index 4ea5a9c46..b0dec5f61 100644
--- a/cmd/containerboot/serve.go
+++ b/cmd/containerboot/serve.go
@@ -28,10 +28,11 @@ import (
// applies it to lc. It exits when ctx is canceled. cdChanged is a channel that
// is written to when the certDomain changes, causing the serve config to be
// re-read and applied.
-func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *local.Client, kc *kubeClient) {
+func watchServeConfigChanges(ctx context.Context, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *local.Client, kc *kubeClient, cfg *settings) {
if certDomainAtomic == nil {
panic("certDomainAtomic must not be nil")
}
+
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
if w, err := fsnotify.NewWatcher(); err != nil {
@@ -43,7 +44,7 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
tickChan = ticker.C
} else {
defer w.Close()
- if err := w.Add(filepath.Dir(path)); err != nil {
+ if err := w.Add(filepath.Dir(cfg.ServeConfigPath)); err != nil {
log.Fatalf("serve proxy: failed to add fsnotify watch: %v", err)
}
eventChan = w.Events
@@ -51,6 +52,14 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
var certDomain string
var prevServeConfig *ipn.ServeConfig
+ var cm certManager
+ if cfg.CertShareMode == "rw" {
+ cm = certManager{
+ parentCtx: ctx,
+ certLoops: make(map[string]context.CancelFunc),
+ lc: lc,
+ }
+ }
for {
select {
case <-ctx.Done():
@@ -63,12 +72,12 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
// k8s handles these mounts. So just re-read the file and apply it
// if it's changed.
}
- sc, err := readServeConfig(path, certDomain)
+ sc, err := readServeConfig(cfg.ServeConfigPath, certDomain)
if err != nil {
log.Fatalf("serve proxy: failed to read serve config: %v", err)
}
if sc == nil {
- log.Printf("serve proxy: no serve config at %q, skipping", path)
+ log.Printf("serve proxy: no serve config at %q, skipping", cfg.ServeConfigPath)
continue
}
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
@@ -83,6 +92,12 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
}
}
prevServeConfig = sc
+ if cfg.CertShareMode != "rw" {
+ continue
+ }
+ if err := cm.ensureCertLoops(ctx, sc); err != nil {
+ log.Fatalf("serve proxy: error ensuring cert loops: %v", err)
+ }
}
}
@@ -96,6 +111,7 @@ func certDomainFromNetmap(nm *netmap.NetworkMap) string {
// localClient is a subset of [local.Client] that can be mocked for testing.
type localClient interface {
SetServeConfig(context.Context, *ipn.ServeConfig) error
+ CertPair(context.Context, string) ([]byte, []byte, error)
}
func updateServeConfig(ctx context.Context, sc *ipn.ServeConfig, certDomain string, lc localClient) error {
diff --git a/cmd/containerboot/settings.go b/cmd/containerboot/settings.go
index 0da18e52c..142221b56 100644
--- a/cmd/containerboot/settings.go
+++ b/cmd/containerboot/settings.go
@@ -74,6 +74,7 @@ type settings struct {
HealthCheckEnabled bool
DebugAddrPort string
EgressProxiesCfgPath string
+ CertShareMode string // Possible values 'ro' (readonly), 'rw' (read-write)
}
func configFromEnv() (*settings, error) {
@@ -128,6 +129,17 @@ func configFromEnv() (*settings, error) {
cfg.PodIPv6 = parsed.String()
}
}
+ // If cert share is enabled, set the replica as read or write. Only 0th
+ // replica should be able to write.
+ isInCertShareMode := defaultBool("TS_EXPERIMENTAL_CERT_SHARE", false)
+ if isInCertShareMode {
+ cfg.CertShareMode = "ro"
+ podName := os.Getenv("POD_NAME")
+ if strings.HasSuffix(podName, "-0") {
+ cfg.CertShareMode = "rw"
+ }
+ }
+
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %v", err)
}
diff --git a/cmd/containerboot/tailscaled.go b/cmd/containerboot/tailscaled.go
index 01ee96d3a..654b34757 100644
--- a/cmd/containerboot/tailscaled.go
+++ b/cmd/containerboot/tailscaled.go
@@ -33,6 +33,9 @@ func startTailscaled(ctx context.Context, cfg *settings) (*local.Client, *os.Pro
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
+ if cfg.CertShareMode != "" {
+ cmd.Env = append(os.Environ(), "TS_CERT_SHARE_MODE="+cfg.CertShareMode)
+ }
log.Printf("Starting tailscaled")
if err := cmd.Start(); err != nil {
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
diff --git a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml
index 7056ef42f..5bf50617e 100644
--- a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml
+++ b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml
@@ -75,7 +75,7 @@ rules:
verbs: ["get", "list", "watch", "create", "update", "deletecollection"]
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
- verbs: ["get", "create", "patch", "update", "list", "watch"]
+ verbs: ["get", "create", "patch", "update", "list", "watch", "deletecollection"]
- apiGroups: ["monitoring.coreos.com"]
resources: ["servicemonitors"]
verbs: ["get", "list", "update", "create", "delete"]
diff --git a/cmd/k8s-operator/egress-pod-readiness.go b/cmd/k8s-operator/egress-pod-readiness.go
index a6c57bf9d..05cf1aa1a 100644
--- a/cmd/k8s-operator/egress-pod-readiness.go
+++ b/cmd/k8s-operator/egress-pod-readiness.go
@@ -112,9 +112,9 @@ func (er *egressPodsReconciler) Reconcile(ctx context.Context, req reconcile.Req
}
// Get all ClusterIP Services for all egress targets exposed to cluster via this ProxyGroup.
lbls := map[string]string{
- LabelManaged: "true",
- labelProxyGroup: proxyGroupName,
- labelSvcType: typeEgress,
+ kubetypes.LabelManaged: "true",
+ labelProxyGroup: proxyGroupName,
+ labelSvcType: typeEgress,
}
svcs := &corev1.ServiceList{}
if err := er.List(ctx, svcs, client.InNamespace(er.tsNamespace), client.MatchingLabels(lbls)); err != nil {
diff --git a/cmd/k8s-operator/egress-services.go b/cmd/k8s-operator/egress-services.go
index e997e5884..7103205ac 100644
--- a/cmd/k8s-operator/egress-services.go
+++ b/cmd/k8s-operator/egress-services.go
@@ -680,12 +680,12 @@ func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, ts
// should probably validate and truncate (?) the names is they are too long.
func egressSvcChildResourceLabels(svc *corev1.Service) map[string]string {
return map[string]string{
- LabelManaged: "true",
- LabelParentType: "svc",
- LabelParentName: svc.Name,
- LabelParentNamespace: svc.Namespace,
- labelProxyGroup: svc.Annotations[AnnotationProxyGroup],
- labelSvcType: typeEgress,
+ kubetypes.LabelManaged: "true",
+ LabelParentType: "svc",
+ LabelParentName: svc.Name,
+ LabelParentNamespace: svc.Namespace,
+ labelProxyGroup: svc.Annotations[AnnotationProxyGroup],
+ labelSvcType: typeEgress,
}
}
diff --git a/cmd/k8s-operator/ingress-for-pg.go b/cmd/k8s-operator/ingress-for-pg.go
index 85a64a336..8adbd86cd 100644
--- a/cmd/k8s-operator/ingress-for-pg.go
+++ b/cmd/k8s-operator/ingress-for-pg.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net"
"net/http"
"reflect"
"slices"
@@ -22,6 +23,7 @@ import (
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
+ rbacv1 "k8s.io/api/rbac/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -34,6 +36,7 @@ import (
"tailscale.com/ipn/ipnstate"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
+ "tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
@@ -243,7 +246,12 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
return false, nil
}
- // 3. Ensure that the serve config for the ProxyGroup contains the VIPService.
+ // 3. Ensure that TLS Secret and RBAC exists
+ if err := r.ensureCertResources(ctx, pgName, dnsName); err != nil {
+ return false, fmt.Errorf("error ensuring cert resources: %w", err)
+ }
+
+ // 4. Ensure that the serve config for the ProxyGroup contains the VIPService.
cm, cfg, err := r.proxyGroupServeConfig(ctx, pgName)
if err != nil {
return false, fmt.Errorf("error getting Ingress serve config: %w", err)
@@ -400,7 +408,6 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
if !found {
logger.Infof("VIPService %q is not owned by any Ingress, cleaning up", vipServiceName)
-
// Delete the VIPService from control if necessary.
svc, _ := r.tsClient.GetVIPService(ctx, vipServiceName)
if svc != nil && isVIPServiceForAnyIngress(svc) {
@@ -418,8 +425,15 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
if err = r.maybeUpdateAdvertiseServicesConfig(ctx, proxyGroupName, vipServiceName, false, logger); err != nil {
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
- delete(cfg.Services, vipServiceName)
- serveConfigChanged = true
+ svcCfg, ok := cfg.Services[vipServiceName]
+ if ok {
+ logger.Infof("Removing VIPService %q from serve config", vipServiceName)
+ delete(cfg.Services, vipServiceName)
+ serveConfigChanged = true
+ }
+ if err := r.cleanupCertResources(ctx, proxyGroupName, svcCfg); err != nil {
+ return false, fmt.Errorf("failed to clean up cert resources: %w", err)
+ }
}
}
@@ -480,6 +494,12 @@ func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string,
if err != nil {
return false, fmt.Errorf("error deleting VIPService: %w", err)
}
+
+ // 3. Clean up any cluster resources
+ if err := r.cleanupCertResources(ctx, pg, cfg.Services[serviceName]); err != nil {
+ return false, fmt.Errorf("failed to clean up cert resources: %w", err)
+ }
+
if cfg == nil || cfg.Services == nil { // user probably deleted the ProxyGroup
return svcChanged, nil
}
@@ -489,7 +509,7 @@ func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string,
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
}
- // 4. Remove the VIPService from the serve config for the ProxyGroup.
+ // 5. Remove the VIPService from the serve config for the ProxyGroup.
logger.Infof("Removing VIPService %q from serve config for ProxyGroup %q", hostname, pg)
delete(cfg.Services, serviceName)
cfgBytes, err := json.Marshal(cfg)
@@ -497,6 +517,7 @@ func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string,
return false, fmt.Errorf("error marshaling serve config: %w", err)
}
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
+
return svcChanged, r.Update(ctx, cm)
}
@@ -791,6 +812,52 @@ func (r *HAIngressReconciler) ownerRefsComment(svc *tailscale.VIPService) (strin
return string(json), nil
}
+// ensureCertResources ensures that the TLS Secret for an HA Ingress and RBAC
+// resources that allow proxies to manage the Secret are created.
+func (r *HAIngressReconciler) ensureCertResources(ctx context.Context, pgName, domain string) error {
+ secret := certSecret(pgName, r.tsNamespace, domain)
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, secret, nil); err != nil {
+ return fmt.Errorf("failed to create or update Secret %s: %w", secret.Name, err)
+ }
+ role := certSecretRole(pgName, r.tsNamespace, domain)
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, nil); err != nil {
+ return fmt.Errorf("failed to create or update Role %s: %w", role.Name, err)
+ }
+ rb := certSecretRoleBinding(pgName, r.tsNamespace, domain)
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, rb, nil); err != nil {
+ return fmt.Errorf("failed to create or update RoleBinding %s: %w", rb.Name, err)
+ }
+ return nil
+}
+
+// cleanupCertResources ensures that the TLS Secret for an HA Ingress and RBAC
+// resources that allow proxies to manage the Secret are deleted.
+func (r *HAIngressReconciler) cleanupCertResources(ctx context.Context, pgName string, cfg *ipn.ServiceConfig) error {
+ if cfg == nil {
+ return nil
+ }
+ for hp := range cfg.Web {
+ host, port, err := net.SplitHostPort(string(hp))
+ if err != nil {
+ return fmt.Errorf("failed to parse HostPort %q: %w", hp, err)
+ }
+ if port != "443" {
+ continue // HTTP endpoint
+ }
+ labels := certResourceLabels(pgName, host)
+ if err := r.DeleteAllOf(ctx, &rbacv1.RoleBinding{}, client.InNamespace(r.tsNamespace), client.MatchingLabels(labels)); err != nil {
+ return fmt.Errorf("error deleting RoleBinding for domain name %s: %w", host, err)
+ }
+ if err := r.DeleteAllOf(ctx, &rbacv1.Role{}, client.InNamespace(r.tsNamespace), client.MatchingLabels(labels)); err != nil {
+ return fmt.Errorf("error deleting Role for domain name %s: %w", host, err)
+ }
+ if err := r.DeleteAllOf(ctx, &corev1.Secret{}, client.InNamespace(r.tsNamespace), client.MatchingLabels(labels)); err != nil {
+ return fmt.Errorf("error deleting Secret for domain name %s: %w", host, err)
+ }
+ }
+ return nil
+}
+
// parseComment returns VIPService comment or nil if none found or not matching the expected format.
func parseComment(vipSvc *tailscale.VIPService) (*comment, error) {
if vipSvc.Comment == "" {
@@ -811,3 +878,79 @@ func parseComment(vipSvc *tailscale.VIPService) (*comment, error) {
func requeueInterval() time.Duration {
return time.Duration(rand.N(5)+5) * time.Minute
}
+
+func certSecretRole(pgName, namespace, domain string) *rbacv1.Role {
+ return &rbacv1.Role{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: certResourceName(domain),
+ Namespace: namespace,
+ Labels: certResourceLabels(pgName, domain),
+ },
+ Rules: []rbacv1.PolicyRule{
+ {
+ APIGroups: []string{""},
+ Resources: []string{"secrets"},
+ Verbs: []string{
+ "get",
+ "list",
+ "patch",
+ "update",
+ },
+ },
+ },
+ }
+}
+
+func certSecretRoleBinding(pgName, namespace, domain string) *rbacv1.RoleBinding {
+ return &rbacv1.RoleBinding{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: certResourceName(domain),
+ Namespace: namespace,
+ Labels: certResourceLabels(pgName, domain),
+ },
+ Subjects: []rbacv1.Subject{
+ {
+ Kind: "ServiceAccount",
+ Name: pgName,
+ Namespace: namespace,
+ },
+ },
+ RoleRef: rbacv1.RoleRef{
+ Kind: "Role",
+ Name: certResourceName(domain),
+ },
+ }
+}
+
+func certSecret(pgName, namespace, domain string) *corev1.Secret {
+ labels := certResourceLabels(pgName, domain)
+ labels[kubetypes.LabelSecretType] = "certs"
+ return &corev1.Secret{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: "v1",
+ Kind: "Secret",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: kubeclient.SanitizeKey(domain),
+ Namespace: namespace,
+ Labels: labels,
+ },
+ Data: map[string][]byte{
+ corev1.TLSCertKey: nil,
+ corev1.TLSPrivateKeyKey: nil,
+ },
+ Type: corev1.SecretTypeTLS,
+ }
+}
+
+func certResourceLabels(pgName, domain string) map[string]string {
+ return map[string]string{
+ kubetypes.LabelManaged: "true",
+ "tailscale.com/proxy-group": pgName,
+ "tailscale.com/domain": domain,
+ }
+}
+
+func certResourceName(domain string) string {
+ return kubeclient.SanitizeKey(domain)
+}
diff --git a/cmd/k8s-operator/metrics_resources.go b/cmd/k8s-operator/metrics_resources.go
index 8516cf8be..0579e3466 100644
--- a/cmd/k8s-operator/metrics_resources.go
+++ b/cmd/k8s-operator/metrics_resources.go
@@ -19,6 +19,7 @@ import (
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
+ "tailscale.com/kube/kubetypes"
)
const (
@@ -222,7 +223,7 @@ func metricsResourceName(stsName string) string {
// proxy.
func metricsResourceLabels(opts *metricsOpts) map[string]string {
lbls := map[string]string{
- LabelManaged: "true",
+ kubetypes.LabelManaged: "true",
labelMetricsTarget: opts.proxyStsName,
labelPromProxyType: opts.proxyType,
labelPromProxyParentName: opts.proxyLabels[LabelParentName],
diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go
index 1dcd130fb..4bfe05ac8 100644
--- a/cmd/k8s-operator/operator.go
+++ b/cmd/k8s-operator/operator.go
@@ -636,8 +636,8 @@ func enqueueAllIngressEgressProxySvcsInNS(ns string, cl client.Client, logger *z
// Get all headless Services for proxies configured using Service.
svcProxyLabels := map[string]string{
- LabelManaged: "true",
- LabelParentType: "svc",
+ kubetypes.LabelManaged: "true",
+ LabelParentType: "svc",
}
svcHeadlessSvcList := &corev1.ServiceList{}
if err := cl.List(ctx, svcHeadlessSvcList, client.InNamespace(ns), client.MatchingLabels(svcProxyLabels)); err != nil {
@@ -650,8 +650,8 @@ func enqueueAllIngressEgressProxySvcsInNS(ns string, cl client.Client, logger *z
// Get all headless Services for proxies configured using Ingress.
ingProxyLabels := map[string]string{
- LabelManaged: "true",
- LabelParentType: "ingress",
+ kubetypes.LabelManaged: "true",
+ LabelParentType: "ingress",
}
ingHeadlessSvcList := &corev1.ServiceList{}
if err := cl.List(ctx, ingHeadlessSvcList, client.InNamespace(ns), client.MatchingLabels(ingProxyLabels)); err != nil {
@@ -718,7 +718,7 @@ func dnsRecordsReconcilerIngressHandler(ns string, isDefaultLoadBalancer bool, c
func isManagedResource(o client.Object) bool {
ls := o.GetLabels()
- return ls[LabelManaged] == "true"
+ return ls[kubetypes.LabelManaged] == "true"
}
func isManagedByType(o client.Object, typ string) bool {
@@ -955,7 +955,7 @@ func egressPodsHandler(_ context.Context, o client.Object) []reconcile.Request {
// returns reconciler requests for all egress EndpointSlices for that ProxyGroup.
func egressEpsFromPGPods(cl client.Client, ns string) handler.MapFunc {
return func(_ context.Context, o client.Object) []reconcile.Request {
- if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
+ if v, ok := o.GetLabels()[kubetypes.LabelManaged]; !ok || v != "true" {
return nil
}
// TODO(irbekrm): for now this is good enough as all ProxyGroups are egress. Add a type check once we
@@ -975,7 +975,7 @@ func egressEpsFromPGPods(cl client.Client, ns string) handler.MapFunc {
// returns reconciler requests for all egress EndpointSlices for that ProxyGroup.
func egressEpsFromPGStateSecrets(cl client.Client, ns string) handler.MapFunc {
return func(_ context.Context, o client.Object) []reconcile.Request {
- if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
+ if v, ok := o.GetLabels()[kubetypes.LabelManaged]; !ok || v != "true" {
return nil
}
// TODO(irbekrm): for now this is good enough as all ProxyGroups are egress. Add a type check once we
@@ -983,7 +983,7 @@ func egressEpsFromPGStateSecrets(cl client.Client, ns string) handler.MapFunc {
if parentType := o.GetLabels()[LabelParentType]; parentType != "proxygroup" {
return nil
}
- if secretType := o.GetLabels()[labelSecretType]; secretType != "state" {
+ if secretType := o.GetLabels()[kubetypes.LabelSecretType]; secretType != "state" {
return nil
}
pg, ok := o.GetLabels()[LabelParentName]
@@ -1000,7 +1000,7 @@ func egressSvcFromEps(_ context.Context, o client.Object) []reconcile.Request {
if typ := o.GetLabels()[labelSvcType]; typ != typeEgress {
return nil
}
- if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
+ if v, ok := o.GetLabels()[kubetypes.LabelManaged]; !ok || v != "true" {
return nil
}
svcName, ok := o.GetLabels()[LabelParentName]
@@ -1145,9 +1145,9 @@ func podsFromEgressEps(cl client.Client, logger *zap.SugaredLogger, ns string) h
return nil
}
podLabels := map[string]string{
- LabelManaged: "true",
- LabelParentType: "proxygroup",
- LabelParentName: eps.Labels[labelProxyGroup],
+ kubetypes.LabelManaged: "true",
+ LabelParentType: "proxygroup",
+ LabelParentName: eps.Labels[labelProxyGroup],
}
podList := &corev1.PodList{}
if err := cl.List(ctx, podList, client.InNamespace(ns),
diff --git a/cmd/k8s-operator/proxygroup_specs.go b/cmd/k8s-operator/proxygroup_specs.go
index 40bbaec17..0fe247e35 100644
--- a/cmd/k8s-operator/proxygroup_specs.go
+++ b/cmd/k8s-operator/proxygroup_specs.go
@@ -178,6 +178,10 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
corev1.EnvVar{
Name: "TS_SERVE_CONFIG",
Value: fmt.Sprintf("/etc/proxies/%s", serveConfigKey),
+ },
+ corev1.EnvVar{
+ Name: "TS_EXPERIMENTAL_CERT_SHARE",
+ Value: "true",
})
}
return append(c.Env, envs...)
@@ -229,6 +233,13 @@ func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role {
APIGroups: []string{""},
Resources: []string{"secrets"},
Verbs: []string{
+ "list",
+ },
+ },
+ {
+ APIGroups: []string{""},
+ Resources: []string{"secrets"},
+ Verbs: []string{
"get",
"patch",
"update",
@@ -320,7 +331,7 @@ func pgIngressCM(pg *tsapi.ProxyGroup, namespace string) *corev1.ConfigMap {
func pgSecretLabels(pgName, typ string) map[string]string {
return pgLabels(pgName, map[string]string{
- labelSecretType: typ, // "config" or "state".
+ kubetypes.LabelSecretType: typ, // "config", "state" or "certs"
})
}
@@ -330,7 +341,7 @@ func pgLabels(pgName string, customLabels map[string]string) map[string]string {
l[k] = v
}
- l[LabelManaged] = "true"
+ l[kubetypes.LabelManaged] = "true"
l[LabelParentType] = "proxygroup"
l[LabelParentName] = pgName
diff --git a/cmd/k8s-operator/sts.go b/cmd/k8s-operator/sts.go
index 0bc9d6fb9..6327a073b 100644
--- a/cmd/k8s-operator/sts.go
+++ b/cmd/k8s-operator/sts.go
@@ -44,11 +44,9 @@ const (
// Labels that the operator sets on StatefulSets and Pods. If you add a
// new label here, do also add it to tailscaleManagedLabels var to
// ensure that it does not get overwritten by ProxyClass configuration.
- LabelManaged = "tailscale.com/managed"
LabelParentType = "tailscale.com/parent-resource-type"
LabelParentName = "tailscale.com/parent-resource"
LabelParentNamespace = "tailscale.com/parent-resource-ns"
- labelSecretType = "tailscale.com/secret-type" // "config" or "state".
// LabelProxyClass can be set by users on tailscale Ingresses and Services that define cluster ingress or
// cluster egress, to specify that configuration in this ProxyClass should be applied to resources created for
@@ -108,7 +106,7 @@ const (
var (
// tailscaleManagedLabels are label keys that tailscale operator sets on StatefulSets and Pods.
- tailscaleManagedLabels = []string{LabelManaged, LabelParentType, LabelParentName, LabelParentNamespace, "app"}
+ tailscaleManagedLabels = []string{kubetypes.LabelManaged, LabelParentType, LabelParentName, LabelParentNamespace, "app"}
// tailscaleManagedAnnotations are annotation keys that tailscale operator sets on StatefulSets and Pods.
tailscaleManagedAnnotations = []string{podAnnotationLastSetClusterIP, podAnnotationLastSetTailnetTargetIP, podAnnotationLastSetTailnetTargetFQDN, podAnnotationLastSetConfigFileHash}
)
diff --git a/cmd/k8s-operator/svc.go b/cmd/k8s-operator/svc.go
index 70c810b25..d6a6f440f 100644
--- a/cmd/k8s-operator/svc.go
+++ b/cmd/k8s-operator/svc.go
@@ -84,10 +84,10 @@ func childResourceLabels(name, ns, typ string) map[string]string {
// proxying. Instead, we have to do our own filtering and tracking with
// labels.
return map[string]string{
- LabelManaged: "true",
- LabelParentName: name,
- LabelParentNamespace: ns,
- LabelParentType: typ,
+ kubetypes.LabelManaged: "true",
+ LabelParentName: name,
+ LabelParentNamespace: ns,
+ LabelParentType: typ,
}
}