diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml
index e101c201f..86e74e441 100644
--- a/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml
+++ b/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml
@@ -103,7 +103,7 @@ spec:
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
type:
description: |-
- Type of the ProxyGroup proxies. Currently the only supported type is egress.
+ Type of the ProxyGroup proxies. Supported types are egress and ingress.
Type is immutable once a ProxyGroup is created.
type: string
enum:
diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml
index aa79fefcb..dc8d0634c 100644
--- a/cmd/k8s-operator/deploy/manifests/operator.yaml
+++ b/cmd/k8s-operator/deploy/manifests/operator.yaml
@@ -2876,7 +2876,7 @@ spec:
type: array
type:
description: |-
- Type of the ProxyGroup proxies. Currently the only supported type is egress.
+ Type of the ProxyGroup proxies. Supported types are egress and ingress.
Type is immutable once a ProxyGroup is created.
enum:
- egress
diff --git a/cmd/k8s-operator/ingress-for-pg.go b/cmd/k8s-operator/ingress-for-pg.go
index 687f70d7b..3df5a07ee 100644
--- a/cmd/k8s-operator/ingress-for-pg.go
+++ b/cmd/k8s-operator/ingress-for-pg.go
@@ -49,6 +49,7 @@ const (
// FinalizerNamePG is the finalizer used by the IngressPGReconciler
FinalizerNamePG = "tailscale.com/ingress-pg-finalizer"
+ indexIngressProxyGroup = ".metadata.annotations.ingress-proxy-group"
// annotationHTTPEndpoint can be used to configure the Ingress to expose an HTTP endpoint to tailnet (as
// well as the default HTTPS endpoint).
annotationHTTPEndpoint = "tailscale.com/http-endpoint"
diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go
index 69ee51c9b..1f637927b 100644
--- a/cmd/k8s-operator/operator.go
+++ b/cmd/k8s-operator/operator.go
@@ -9,6 +9,7 @@ package main
import (
"context"
+ "fmt"
"net/http"
"os"
"regexp"
@@ -39,6 +40,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
+ "tailscale.com/client/local"
"tailscale.com/client/tailscale"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
@@ -331,6 +333,40 @@ func runReconcilers(opts reconcilerOpts) {
if err != nil {
startlog.Fatalf("could not create ingress reconciler: %v", err)
}
+ lc, err := opts.tsServer.LocalClient()
+ if err != nil {
+ startlog.Fatalf("could not get local client: %v", err)
+ }
+ id, err := id(context.Background(), lc)
+ if err != nil {
+ startlog.Fatalf("error determining stable ID of the operator's Tailscale device: %v", err)
+ }
+ ingressProxyGroupFilter := handler.EnqueueRequestsFromMapFunc(ingressesFromIngressProxyGroup(mgr.GetClient(), opts.log))
+ err = builder.
+ ControllerManagedBy(mgr).
+ For(&networkingv1.Ingress{}).
+ Named("ingress-pg-reconciler").
+ Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngressPG(mgr.GetClient(), startlog))).
+ Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(HAIngressesFromSecret(mgr.GetClient(), startlog))).
+ Watches(&tsapi.ProxyGroup{}, ingressProxyGroupFilter).
+ Complete(&HAIngressReconciler{
+ recorder: eventRecorder,
+ tsClient: opts.tsClient,
+ tsnetServer: opts.tsServer,
+ defaultTags: strings.Split(opts.proxyTags, ","),
+ Client: mgr.GetClient(),
+ logger: opts.log.Named("ingress-pg-reconciler"),
+ lc: lc,
+ operatorID: id,
+ tsNamespace: opts.tailscaleNamespace,
+ })
+ if err != nil {
+ startlog.Fatalf("could not create ingress-pg-reconciler: %v", err)
+ }
+ if err := mgr.GetFieldIndexer().IndexField(context.Background(), new(networkingv1.Ingress), indexIngressProxyGroup, indexPGIngresses); err != nil {
+ startlog.Fatalf("failed setting up indexer for HA Ingresses: %v", err)
+ }
+
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("connector"))
// If a ProxyClassChanges, enqueue all Connectors that have
// .spec.proxyClass set to the name of this ProxyClass.
@@ -1003,6 +1039,65 @@ func reconcileRequestsForPG(pg string, cl client.Client, ns string) []reconcile.
return reqs
}
+func isTLSSecret(secret *corev1.Secret) bool {
+ return secret.Type == corev1.SecretTypeTLS &&
+ secret.ObjectMeta.Labels[kubetypes.LabelManaged] == "true" &&
+ secret.ObjectMeta.Labels[kubetypes.LabelSecretType] == "certs" &&
+ secret.ObjectMeta.Labels[labelDomain] != "" &&
+ secret.ObjectMeta.Labels[labelProxyGroup] != ""
+}
+
+func isPGStateSecret(secret *corev1.Secret) bool {
+ return secret.ObjectMeta.Labels[kubetypes.LabelManaged] == "true" &&
+ secret.ObjectMeta.Labels[LabelParentType] == "proxygroup" &&
+ secret.ObjectMeta.Labels[kubetypes.LabelSecretType] == "state"
+}
+
+// HAIngressesFromSecret returns a handler that returns reconcile requests for
+// all HA Ingresses that should be reconciled in response to a Secret event.
+func HAIngressesFromSecret(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
+ return func(ctx context.Context, o client.Object) []reconcile.Request {
+ secret, ok := o.(*corev1.Secret)
+ if !ok {
+ logger.Infof("[unexpected] Secret handler triggered for an object that is not a Secret")
+ return nil
+ }
+ if isTLSSecret(secret) {
+ return []reconcile.Request{
+ {
+ NamespacedName: types.NamespacedName{
+ Namespace: secret.ObjectMeta.Labels[LabelParentNamespace],
+ Name: secret.ObjectMeta.Labels[LabelParentName],
+ },
+ },
+ }
+ }
+ if !isPGStateSecret(secret) {
+ return nil
+ }
+ pgName, ok := secret.ObjectMeta.Labels[LabelParentName]
+ if !ok {
+ return nil
+ }
+
+ ingList := &networkingv1.IngressList{}
+ if err := cl.List(ctx, ingList, client.MatchingFields{indexIngressProxyGroup: pgName}); err != nil {
+ logger.Infof("error listing Ingresses, skipping a reconcile for event on Secret %s: %v", secret.Name, err)
+ return nil
+ }
+ reqs := make([]reconcile.Request, 0)
+ for _, ing := range ingList.Items {
+ reqs = append(reqs, reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Namespace: ing.Namespace,
+ Name: ing.Name,
+ },
+ })
+ }
+ return reqs
+ }
+}
+
// egressSvcsFromEgressProxyGroup is an event handler for egress ProxyGroups. It returns reconcile requests for all
// user-created ExternalName Services that should be exposed on this ProxyGroup.
func egressSvcsFromEgressProxyGroup(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
@@ -1033,6 +1128,36 @@ func egressSvcsFromEgressProxyGroup(cl client.Client, logger *zap.SugaredLogger)
}
}
+// ingressesFromIngressProxyGroup is an event handler for ingress ProxyGroups. It returns reconcile requests for all
+// user-created Ingresses that should be exposed on this ProxyGroup.
+func ingressesFromIngressProxyGroup(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
+ return func(ctx context.Context, o client.Object) []reconcile.Request {
+ pg, ok := o.(*tsapi.ProxyGroup)
+ if !ok {
+ logger.Infof("[unexpected] ProxyGroup handler triggered for an object that is not a ProxyGroup")
+ return nil
+ }
+ if pg.Spec.Type != tsapi.ProxyGroupTypeIngress {
+ return nil
+ }
+ ingList := &networkingv1.IngressList{}
+ if err := cl.List(ctx, ingList, client.MatchingFields{indexIngressProxyGroup: pg.Name}); err != nil {
+ logger.Infof("error listing Ingresses: %v, skipping a reconcile for event on ProxyGroup %s", err, pg.Name)
+ return nil
+ }
+ reqs := make([]reconcile.Request, 0)
+ for _, svc := range ingList.Items {
+ reqs = append(reqs, reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Namespace: svc.Namespace,
+ Name: svc.Name,
+ },
+ })
+ }
+ return reqs
+ }
+}
+
// epsFromExternalNameService is an event handler for ExternalName Services that define a Tailscale egress service that
// should be exposed on a ProxyGroup. It returns reconcile requests for EndpointSlices created for this Service.
func epsFromExternalNameService(cl client.Client, logger *zap.SugaredLogger, ns string) handler.MapFunc {
@@ -1153,7 +1278,63 @@ func indexEgressServices(o client.Object) []string {
return []string{o.GetAnnotations()[AnnotationProxyGroup]}
}
+// indexPGIngresses adds a local index to a cached Tailscale Ingresses meant to be exposed on a ProxyGroup. The index is
+// used a list filter.
+func indexPGIngresses(o client.Object) []string {
+ if !hasProxyGroupAnnotation(o) {
+ return nil
+ }
+ return []string{o.GetAnnotations()[AnnotationProxyGroup]}
+}
+
+// serviceHandlerForIngressPG returns a handler for Service events that ensures that if the Service
+// associated with an event is a backend Service for a tailscale Ingress with ProxyGroup annotation,
+// the associated Ingress gets reconciled.
+func serviceHandlerForIngressPG(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
+ return func(ctx context.Context, o client.Object) []reconcile.Request {
+ ingList := networkingv1.IngressList{}
+ if err := cl.List(ctx, &ingList, client.InNamespace(o.GetNamespace())); err != nil {
+ logger.Debugf("error listing Ingresses: %v", err)
+ return nil
+ }
+ reqs := make([]reconcile.Request, 0)
+ for _, ing := range ingList.Items {
+ if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName {
+ continue
+ }
+ if !hasProxyGroupAnnotation(&ing) {
+ continue
+ }
+ if ing.Spec.DefaultBackend != nil && ing.Spec.DefaultBackend.Service != nil && ing.Spec.DefaultBackend.Service.Name == o.GetName() {
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
+ }
+ for _, rule := range ing.Spec.Rules {
+ if rule.HTTP == nil {
+ continue
+ }
+ for _, path := range rule.HTTP.Paths {
+ if path.Backend.Service != nil && path.Backend.Service.Name == o.GetName() {
+ reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
+ }
+ }
+ }
+ }
+ return reqs
+ }
+}
+
func hasProxyGroupAnnotation(obj client.Object) bool {
ing := obj.(*networkingv1.Ingress)
return ing.Annotations[AnnotationProxyGroup] != ""
}
+
+func id(ctx context.Context, lc *local.Client) (string, error) {
+ st, err := lc.StatusWithoutPeers(ctx)
+ if err != nil {
+ return "", fmt.Errorf("error getting tailscale status: %w", err)
+ }
+ if st.Self == nil {
+ return "", fmt.Errorf("unexpected: device's status does not contain self status")
+ }
+ return string(st.Self.ID), nil
+}
diff --git a/k8s-operator/api.md b/k8s-operator/api.md
index f885ded1e..190f99d24 100644
--- a/k8s-operator/api.md
+++ b/k8s-operator/api.md
@@ -600,7 +600,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
-| `type` _[ProxyGroupType](#proxygrouptype)_ | Type of the ProxyGroup proxies. Currently the only supported type is egress.
Type is immutable once a ProxyGroup is created. | | Enum: [egress ingress]
Type: string
|
+| `type` _[ProxyGroupType](#proxygrouptype)_ | Type of the ProxyGroup proxies. Supported types are egress and ingress.
Type is immutable once a ProxyGroup is created. | | Enum: [egress ingress]
Type: string
|
| `tags` _[Tags](#tags)_ | Tags that the Tailscale devices will be tagged with. Defaults to [tag:k8s].
If you specify custom tags here, make sure you also make the operator
an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a ProxyGroup device has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. | | Pattern: `^tag:[a-zA-Z][a-zA-Z0-9-]*$`
Type: string
|
| `replicas` _integer_ | Replicas specifies how many replicas to create the StatefulSet with.
Defaults to 2. | | Minimum: 0
|
| `hostnamePrefix` _[HostnamePrefix](#hostnameprefix)_ | HostnamePrefix is the hostname prefix to use for tailnet devices created
by the ProxyGroup. Each device will have the integer number from its
StatefulSet pod appended to this prefix to form the full hostname.
HostnamePrefix can contain lower case letters, numbers and dashes, it
must not start with a dash and must be between 1 and 62 characters long. | | Pattern: `^[a-z0-9][a-z0-9-]{0,61}$`
Type: string
|
diff --git a/k8s-operator/apis/v1alpha1/types_proxygroup.go b/k8s-operator/apis/v1alpha1/types_proxygroup.go
index cb9f678f8..f95fc58d0 100644
--- a/k8s-operator/apis/v1alpha1/types_proxygroup.go
+++ b/k8s-operator/apis/v1alpha1/types_proxygroup.go
@@ -48,7 +48,7 @@ type ProxyGroupList struct {
}
type ProxyGroupSpec struct {
- // Type of the ProxyGroup proxies. Currently the only supported type is egress.
+ // Type of the ProxyGroup proxies. Supported types are egress and ingress.
// Type is immutable once a ProxyGroup is created.
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="ProxyGroup type is immutable"
Type ProxyGroupType `json:"type"`