diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml index a620c3887..f89e38453 100644 --- a/cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml +++ b/cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml @@ -2215,6 +2215,22 @@ spec: https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-devices Defaults to false. type: boolean + useLetsEncryptStagingEnvironment: + description: |- + Set UseLetsEncryptStagingEnvironment to true to issue TLS + certificates for any HTTPS endpoints exposed to the tailnet from + LetsEncrypt's staging environment. + https://letsencrypt.org/docs/staging-environment/ + This setting only affects Tailscale Ingress resources. + By default Ingress TLS certificates are issued from LetsEncrypt's + production environment. + Changing this setting true -> false, will result in any + existing certs being re-issued from the production environment. + Changing this setting false (default) -> true, when certs have already + been provisioned from production environment will NOT result in certs + being re-issued from the staging environment before they need to be + renewed. + type: boolean status: description: |- Status of the ProxyClass. This is set and managed automatically. diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml index 9ee3b441a..dc8d0634c 100644 --- a/cmd/k8s-operator/deploy/manifests/operator.yaml +++ b/cmd/k8s-operator/deploy/manifests/operator.yaml @@ -2685,6 +2685,22 @@ spec: Defaults to false. type: boolean type: object + useLetsEncryptStagingEnvironment: + description: |- + Set UseLetsEncryptStagingEnvironment to true to issue TLS + certificates for any HTTPS endpoints exposed to the tailnet from + LetsEncrypt's staging environment. + https://letsencrypt.org/docs/staging-environment/ + This setting only affects Tailscale Ingress resources. + By default Ingress TLS certificates are issued from LetsEncrypt's + production environment. + Changing this setting true -> false, will result in any + existing certs being re-issued from the production environment. + Changing this setting false (default) -> true, when certs have already + been provisioned from production environment will NOT result in certs + being re-issued from the staging environment before they need to be + renewed. + type: boolean type: object status: description: |- diff --git a/cmd/k8s-operator/ingress_test.go b/cmd/k8s-operator/ingress_test.go index 74eddff56..f9623850c 100644 --- a/cmd/k8s-operator/ingress_test.go +++ b/cmd/k8s-operator/ingress_test.go @@ -6,6 +6,7 @@ package main import ( + "context" "testing" "go.uber.org/zap" @@ -15,17 +16,18 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "tailscale.com/ipn" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/kube/kubetypes" + "tailscale.com/tstest" "tailscale.com/types/ptr" "tailscale.com/util/mak" ) func TestTailscaleIngress(t *testing.T) { - tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}} - fc := fake.NewFakeClient(tsIngressClass) + fc := fake.NewFakeClient(ingressClass()) ft := &fakeTSClient{} fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}} zl, err := zap.NewDevelopment() @@ -46,45 +48,8 @@ func TestTailscaleIngress(t *testing.T) { } // 1. Resources get created for regular Ingress - ing := &networkingv1.Ingress{ - TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("tailscale"), - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test", - Port: networkingv1.ServiceBackendPort{ - Number: 8080, - }, - }, - }, - TLS: []networkingv1.IngressTLS{ - {Hosts: []string{"default-test"}}, - }, - }, - } - mustCreate(t, fc, ing) - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "1.2.3.4", - Ports: []corev1.ServicePort{{ - Port: 8080, - Name: "http"}, - }, - }, - }) + mustCreate(t, fc, ingress()) + mustCreate(t, fc, service()) expectReconciled(t, ingR, "default", "test") @@ -114,6 +79,9 @@ func TestTailscaleIngress(t *testing.T) { mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net")) }) expectReconciled(t, ingR, "default", "test") + + // Get the ingress and update it with expected changes + ing := ingress() ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer") ing.Status.LoadBalancer = networkingv1.IngressLoadBalancerStatus{ Ingress: []networkingv1.IngressLoadBalancerIngress{ @@ -143,8 +111,7 @@ func TestTailscaleIngress(t *testing.T) { } func TestTailscaleIngressHostname(t *testing.T) { - tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}} - fc := fake.NewFakeClient(tsIngressClass) + fc := fake.NewFakeClient(ingressClass()) ft := &fakeTSClient{} fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}} zl, err := zap.NewDevelopment() @@ -165,45 +132,8 @@ func TestTailscaleIngressHostname(t *testing.T) { } // 1. Resources get created for regular Ingress - ing := &networkingv1.Ingress{ - TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("tailscale"), - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test", - Port: networkingv1.ServiceBackendPort{ - Number: 8080, - }, - }, - }, - TLS: []networkingv1.IngressTLS{ - {Hosts: []string{"default-test"}}, - }, - }, - } - mustCreate(t, fc, ing) - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "1.2.3.4", - Ports: []corev1.ServicePort{{ - Port: 8080, - Name: "http"}, - }, - }, - }) + mustCreate(t, fc, ingress()) + mustCreate(t, fc, service()) expectReconciled(t, ingR, "default", "test") @@ -241,8 +171,10 @@ func TestTailscaleIngressHostname(t *testing.T) { mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net")) }) expectReconciled(t, ingR, "default", "test") - ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer") + // Get the ingress and update it with expected changes + ing := ingress() + ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer") expectEqual(t, fc, ing) // 3. Ingress proxy with capability version >= 110 advertises HTTPS endpoint @@ -299,10 +231,9 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) { Annotations: map[string]string{"bar.io/foo": "some-val"}, Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}}, } - tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}} fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). - WithObjects(pc, tsIngressClass). + WithObjects(pc, ingressClass()). WithStatusSubresource(pc). Build() ft := &fakeTSClient{} @@ -326,45 +257,8 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) { // 1. Ingress is created with no ProxyClass specified, default proxy // resources get configured. - ing := &networkingv1.Ingress{ - TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("tailscale"), - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test", - Port: networkingv1.ServiceBackendPort{ - Number: 8080, - }, - }, - }, - TLS: []networkingv1.IngressTLS{ - {Hosts: []string{"default-test"}}, - }, - }, - } - mustCreate(t, fc, ing) - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "1.2.3.4", - Ports: []corev1.ServicePort{{ - Port: 8080, - Name: "http"}, - }, - }, - }) + mustCreate(t, fc, ingress()) + mustCreate(t, fc, service()) expectReconciled(t, ingR, "default", "test") @@ -432,54 +326,19 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) { ObservedGeneration: 1, }}}, } - ing := &networkingv1.Ingress{ - TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Labels: map[string]string{ - "tailscale.com/proxy-class": "metrics", - }, - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("tailscale"), - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test", - Port: networkingv1.ServiceBackendPort{ - Number: 8080, - }, - }, - }, - TLS: []networkingv1.IngressTLS{ - {Hosts: []string{"default-test"}}, - }, - }, - } - svc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "1.2.3.4", - Ports: []corev1.ServicePort{{ - Port: 8080, - Name: "http"}, - }, - }, - } crd := &apiextensionsv1.CustomResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: serviceMonitorCRD}} - tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}} + + // Create fake client with ProxyClass, IngressClass, Ingress with metrics ProxyClass, and Service + ing := ingress() + ing.Labels = map[string]string{ + LabelProxyClass: "metrics", + } fc := fake.NewClientBuilder(). WithScheme(tsapi.GlobalScheme). - WithObjects(pc, tsIngressClass, ing, svc). + WithObjects(pc, ingressClass(), ing, service()). WithStatusSubresource(pc). Build() + ft := &fakeTSClient{} fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}} zl, err := zap.NewDevelopment() @@ -560,3 +419,118 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) { expectMissing[corev1.Service](t, fc, "operator-ns", metricsResourceName(shortName)) // ServiceMonitor gets garbage collected when the Service is deleted - we cannot test that here. } + +func TestIngressLetsEncryptStaging(t *testing.T) { + cl := tstest.NewClock(tstest.ClockOpts{}) + zl := zap.Must(zap.NewDevelopment()) + + pcLEStaging, pcLEStagingFalse, pcOther := proxyClassesForLEStagingTest() + + testCases := testCasesForLEStagingTests(pcLEStaging, pcLEStagingFalse, pcOther) + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + builder := fake.NewClientBuilder(). + WithScheme(tsapi.GlobalScheme) + + builder = builder.WithObjects(pcLEStaging, pcLEStagingFalse, pcOther). + WithStatusSubresource(pcLEStaging, pcLEStagingFalse, pcOther) + + fc := builder.Build() + + if tt.proxyClassPerResource != "" || tt.defaultProxyClass != "" { + name := tt.proxyClassPerResource + if name == "" { + name = tt.defaultProxyClass + } + setProxyClassReady(t, fc, cl, name) + } + + mustCreate(t, fc, ingressClass()) + mustCreate(t, fc, service()) + ing := ingress() + if tt.proxyClassPerResource != "" { + ing.Labels = map[string]string{ + LabelProxyClass: tt.proxyClassPerResource, + } + } + mustCreate(t, fc, ing) + + ingR := &IngressReconciler{ + Client: fc, + ssr: &tailscaleSTSReconciler{ + Client: fc, + tsClient: &fakeTSClient{}, + tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}}, + defaultTags: []string{"tag:test"}, + operatorNamespace: "operator-ns", + proxyImage: "tailscale/tailscale:test", + }, + logger: zl.Sugar(), + defaultProxyClass: tt.defaultProxyClass, + } + + expectReconciled(t, ingR, "default", "test") + + _, shortName := findGenName(t, fc, "default", "test", "ingress") + sts := &appsv1.StatefulSet{} + if err := fc.Get(context.Background(), client.ObjectKey{Namespace: "operator-ns", Name: shortName}, sts); err != nil { + t.Fatalf("failed to get StatefulSet: %v", err) + } + + if tt.useLEStagingEndpoint { + verifyEnvVar(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL", letsEncryptStagingEndpoint) + } else { + verifyEnvVarNotPresent(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL") + } + }) + } +} + +func ingressClass() *networkingv1.IngressClass { + return &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, + Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}, + } +} + +func service() *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "1.2.3.4", + Ports: []corev1.ServicePort{{ + Port: 8080, + Name: "http"}, + }, + }, + } +} + +func ingress() *networkingv1.Ingress { + return &networkingv1.Ingress{ + TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + UID: types.UID("1234-UID"), + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("tailscale"), + DefaultBackend: &networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test", + Port: networkingv1.ServiceBackendPort{ + Number: 8080, + }, + }, + }, + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"default-test"}}, + }, + }, + } +} diff --git a/cmd/k8s-operator/proxygroup.go b/cmd/k8s-operator/proxygroup.go index 112e5e2b0..f263829d7 100644 --- a/cmd/k8s-operator/proxygroup.go +++ b/cmd/k8s-operator/proxygroup.go @@ -302,7 +302,10 @@ func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, pg *tsapi.Pro if err != nil { return fmt.Errorf("error generating StatefulSet spec: %w", err) } - ss = applyProxyClassToStatefulSet(proxyClass, ss, nil, logger) + cfg := &tailscaleSTSConfig{ + proxyType: string(pg.Spec.Type), + } + ss = applyProxyClassToStatefulSet(proxyClass, ss, cfg, logger) capver, err := r.capVerForPG(ctx, pg, logger) if err != nil { return fmt.Errorf("error getting device info: %w", err) diff --git a/cmd/k8s-operator/proxygroup_test.go b/cmd/k8s-operator/proxygroup_test.go index 1f1a39ab0..159329eda 100644 --- a/cmd/k8s-operator/proxygroup_test.go +++ b/cmd/k8s-operator/proxygroup_test.go @@ -518,6 +518,60 @@ func TestIngressAdvertiseServicesConfigPreserved(t *testing.T) { }) } +func proxyClassesForLEStagingTest() (*tsapi.ProxyClass, *tsapi.ProxyClass, *tsapi.ProxyClass) { + pcLEStaging := &tsapi.ProxyClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "le-staging", + Generation: 1, + }, + Spec: tsapi.ProxyClassSpec{ + UseLetsEncryptStagingEnvironment: true, + }, + } + + pcLEStagingFalse := &tsapi.ProxyClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "le-staging-false", + Generation: 1, + }, + Spec: tsapi.ProxyClassSpec{ + UseLetsEncryptStagingEnvironment: false, + }, + } + + pcOther := &tsapi.ProxyClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other", + Generation: 1, + }, + Spec: tsapi.ProxyClassSpec{}, + } + + return pcLEStaging, pcLEStagingFalse, pcOther +} + +func setProxyClassReady(t *testing.T, fc client.Client, cl *tstest.Clock, name string) *tsapi.ProxyClass { + t.Helper() + pc := &tsapi.ProxyClass{} + if err := fc.Get(context.Background(), client.ObjectKey{Name: name}, pc); err != nil { + t.Fatal(err) + } + pc.Status = tsapi.ProxyClassStatus{ + Conditions: []metav1.Condition{{ + Type: string(tsapi.ProxyClassReady), + Status: metav1.ConditionTrue, + Reason: reasonProxyClassValid, + Message: reasonProxyClassValid, + LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, + ObservedGeneration: pc.Generation, + }}, + } + if err := fc.Status().Update(context.Background(), pc); err != nil { + t.Fatal(err) + } + return pc +} + func verifyProxyGroupCounts(t *testing.T, r *ProxyGroupReconciler, wantIngress, wantEgress int) { t.Helper() if r.ingressProxyGroups.Len() != wantIngress { @@ -541,6 +595,16 @@ func verifyEnvVar(t *testing.T, sts *appsv1.StatefulSet, name, expectedValue str t.Errorf("%s environment variable not found", name) } +func verifyEnvVarNotPresent(t *testing.T, sts *appsv1.StatefulSet, name string) { + t.Helper() + for _, env := range sts.Spec.Template.Spec.Containers[0].Env { + if env.Name == name { + t.Errorf("environment variable %s should not be present", name) + return + } + } +} + func expectProxyGroupResources(t *testing.T, fc client.WithWatch, pg *tsapi.ProxyGroup, shouldExist bool, cfgHash string, proxyClass *tsapi.ProxyClass) { t.Helper() @@ -618,3 +682,146 @@ func addNodeIDToStateSecrets(t *testing.T, fc client.WithWatch, pg *tsapi.ProxyG }) } } + +func TestProxyGroupLetsEncryptStaging(t *testing.T) { + cl := tstest.NewClock(tstest.ClockOpts{}) + zl := zap.Must(zap.NewDevelopment()) + + // Set up test cases- most are shared with non-HA Ingress. + type proxyGroupLETestCase struct { + leStagingTestCase + pgType tsapi.ProxyGroupType + } + pcLEStaging, pcLEStagingFalse, pcOther := proxyClassesForLEStagingTest() + sharedTestCases := testCasesForLEStagingTests(pcLEStaging, pcLEStagingFalse, pcOther) + var tests []proxyGroupLETestCase + for _, tt := range sharedTestCases { + tests = append(tests, proxyGroupLETestCase{ + leStagingTestCase: tt, + pgType: tsapi.ProxyGroupTypeIngress, + }) + } + tests = append(tests, proxyGroupLETestCase{ + leStagingTestCase: leStagingTestCase{ + name: "egress_pg_with_staging_proxyclass", + proxyClassPerResource: "le-staging", + useLEStagingEndpoint: false, + }, + pgType: tsapi.ProxyGroupTypeEgress, + }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder := fake.NewClientBuilder(). + WithScheme(tsapi.GlobalScheme) + + // Pre-populate the fake client with ProxyClasses. + builder = builder.WithObjects(pcLEStaging, pcLEStagingFalse, pcOther). + WithStatusSubresource(pcLEStaging, pcLEStagingFalse, pcOther) + + fc := builder.Build() + + // If the test case needs a ProxyClass to exist, ensure it is set to Ready. + if tt.proxyClassPerResource != "" || tt.defaultProxyClass != "" { + name := tt.proxyClassPerResource + if name == "" { + name = tt.defaultProxyClass + } + setProxyClassReady(t, fc, cl, name) + } + + // Create ProxyGroup + pg := &tsapi.ProxyGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.ProxyGroupSpec{ + Type: tt.pgType, + Replicas: ptr.To[int32](1), + ProxyClass: tt.proxyClassPerResource, + }, + } + mustCreate(t, fc, pg) + + reconciler := &ProxyGroupReconciler{ + tsNamespace: tsNamespace, + proxyImage: testProxyImage, + defaultTags: []string{"tag:test"}, + defaultProxyClass: tt.defaultProxyClass, + Client: fc, + tsClient: &fakeTSClient{}, + l: zl.Sugar(), + clock: cl, + } + + expectReconciled(t, reconciler, "", pg.Name) + + // Verify that the StatefulSet created for ProxyGrup has + // the expected setting for the staging endpoint. + sts := &appsv1.StatefulSet{} + if err := fc.Get(context.Background(), client.ObjectKey{Namespace: tsNamespace, Name: pg.Name}, sts); err != nil { + t.Fatalf("failed to get StatefulSet: %v", err) + } + + if tt.useLEStagingEndpoint { + verifyEnvVar(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL", letsEncryptStagingEndpoint) + } else { + verifyEnvVarNotPresent(t, sts, "TS_DEBUG_ACME_DIRECTORY_URL") + } + }) + } +} + +type leStagingTestCase struct { + name string + // ProxyClass set on ProxyGroup or Ingress resource. + proxyClassPerResource string + // Default ProxyClass. + defaultProxyClass string + useLEStagingEndpoint bool +} + +// Shared test cases for LE staging endpoint configuration for ProxyGroup and +// non-HA Ingress. +func testCasesForLEStagingTests(pcLEStaging, pcLEStagingFalse, pcOther *tsapi.ProxyClass) []leStagingTestCase { + return []leStagingTestCase{ + { + name: "with_staging_proxyclass", + proxyClassPerResource: "le-staging", + useLEStagingEndpoint: true, + }, + { + name: "with_staging_proxyclass_false", + proxyClassPerResource: "le-staging-false", + useLEStagingEndpoint: false, + }, + { + name: "with_other_proxyclass", + proxyClassPerResource: "other", + useLEStagingEndpoint: false, + }, + { + name: "no_proxyclass", + proxyClassPerResource: "", + useLEStagingEndpoint: false, + }, + { + name: "with_default_staging_proxyclass", + proxyClassPerResource: "", + defaultProxyClass: "le-staging", + useLEStagingEndpoint: true, + }, + { + name: "with_default_other_proxyclass", + proxyClassPerResource: "", + defaultProxyClass: "other", + useLEStagingEndpoint: false, + }, + { + name: "with_default_staging_proxyclass_false", + proxyClassPerResource: "", + defaultProxyClass: "le-staging-false", + useLEStagingEndpoint: false, + }, + } +} diff --git a/cmd/k8s-operator/sts.go b/cmd/k8s-operator/sts.go index 6327a073b..7434ea79d 100644 --- a/cmd/k8s-operator/sts.go +++ b/cmd/k8s-operator/sts.go @@ -102,6 +102,8 @@ const ( envVarTSLocalAddrPort = "TS_LOCAL_ADDR_PORT" defaultLocalAddrPort = 9002 // metrics and health check port + + letsEncryptStagingEndpoint = "https://acme-staging-v02.api.letsencrypt.org/directory" ) var ( @@ -783,6 +785,17 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet, enableEndpoints(ss, metricsEnabled, debugEnabled) } } + if pc.Spec.UseLetsEncryptStagingEnvironment && (stsCfg.proxyType == proxyTypeIngressResource || stsCfg.proxyType == string(tsapi.ProxyGroupTypeIngress)) { + for i, c := range ss.Spec.Template.Spec.Containers { + if c.Name == "tailscale" { + ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env, corev1.EnvVar{ + Name: "TS_DEBUG_ACME_DIRECTORY_URL", + Value: letsEncryptStagingEndpoint, + }) + break + } + } + } if pc.Spec.StatefulSet == nil { return ss diff --git a/k8s-operator/api.md b/k8s-operator/api.md index fae25b1f6..190f99d24 100644 --- a/k8s-operator/api.md +++ b/k8s-operator/api.md @@ -517,6 +517,7 @@ _Appears in:_ | `statefulSet` _[StatefulSet](#statefulset)_ | Configuration parameters for the proxy's StatefulSet. Tailscale
Kubernetes operator deploys a StatefulSet for each of the user
configured proxies (Tailscale Ingress, Tailscale Service, Connector). | | | | `metrics` _[Metrics](#metrics)_ | Configuration for proxy metrics. Metrics are currently not supported
for egress proxies and for Ingress proxies that have been configured
with tailscale.com/experimental-forward-cluster-traffic-via-ingress
annotation. Note that the metrics are currently considered unstable
and will likely change in breaking ways in the future - we only
recommend that you use those for debugging purposes. | | | | `tailscale` _[TailscaleConfig](#tailscaleconfig)_ | TailscaleConfig contains options to configure the tailscale-specific
parameters of proxies. | | | +| `useLetsEncryptStagingEnvironment` _boolean_ | Set UseLetsEncryptStagingEnvironment to true to issue TLS
certificates for any HTTPS endpoints exposed to the tailnet from
LetsEncrypt's staging environment.
https://letsencrypt.org/docs/staging-environment/
This setting only affects Tailscale Ingress resources.
By default Ingress TLS certificates are issued from LetsEncrypt's
production environment.
Changing this setting true -> false, will result in any
existing certs being re-issued from the production environment.
Changing this setting false (default) -> true, when certs have already
been provisioned from production environment will NOT result in certs
being re-issued from the staging environment before they need to be
renewed. | | | #### ProxyClassStatus diff --git a/k8s-operator/apis/v1alpha1/types_proxyclass.go b/k8s-operator/apis/v1alpha1/types_proxyclass.go index 549234fef..3fde0b37a 100644 --- a/k8s-operator/apis/v1alpha1/types_proxyclass.go +++ b/k8s-operator/apis/v1alpha1/types_proxyclass.go @@ -66,6 +66,21 @@ type ProxyClassSpec struct { // parameters of proxies. // +optional TailscaleConfig *TailscaleConfig `json:"tailscale,omitempty"` + // Set UseLetsEncryptStagingEnvironment to true to issue TLS + // certificates for any HTTPS endpoints exposed to the tailnet from + // LetsEncrypt's staging environment. + // https://letsencrypt.org/docs/staging-environment/ + // This setting only affects Tailscale Ingress resources. + // By default Ingress TLS certificates are issued from LetsEncrypt's + // production environment. + // Changing this setting true -> false, will result in any + // existing certs being re-issued from the production environment. + // Changing this setting false (default) -> true, when certs have already + // been provisioned from production environment will NOT result in certs + // being re-issued from the staging environment before they need to be + // renewed. + // +optional + UseLetsEncryptStagingEnvironment bool `json:"useLetsEncryptStagingEnvironment,omitempty"` } type TailscaleConfig struct {