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 {