tailscale/cmd/k8s-operator/dnsrecords.go
Irbe Krumina 5d1cc44fa3 WIP: MagicDNS resolution in cluster
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-01-30 11:38:10 +02:00

325 lines
11 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
// tailscale-operator provides a way to expose services running in a Kubernetes
// cluster to your Tailnet and to make Tailscale nodes available to cluster
// workloads
package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
networkingv1 "k8s.io/api/networking/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
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/reconcile"
"tailscale.com/client/tailscale/apitype"
kube "tailscale.com/k8s-operator"
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/util/mak"
)
const (
dnsConfigKey = "dns.json"
configMapName = "dnsconfig"
dnsRecordsRecocilerFinalizer = "tailscale.com/dns-records-reconciler"
annotationTSMagicDNSName = "tailscale.com/magic-dns"
)
// dnsRecordsReconciler knows how to update ts.net nameserver with records
// of a tailnet MagicDNS name to kube Service endpoints.
type dnsRecordsReconciler struct {
client.Client
// namespace in which tailscale resources get provisioned
tsNamespace string
// localClient knows how to talk to tailscaled local API
localAPIClient localClient
logger *zap.SugaredLogger
isDefaultLoadBalancer bool
}
type localClient interface {
WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error)
}
func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
logger := dnsRR.logger.With("EndpointSlice", req.Name)
logger.Debugf("starting reconcile")
defer logger.Debugf("reconcile finished")
// Check that this is an EndpointSlice is for a headless Service for a
// tailscale proxy type that we support creating DNS records for.
// Currently this is cluster egress or L7 cluster ingress.
eps := new(discoveryv1.EndpointSlice)
err = dnsRR.Get(ctx, req.NamespacedName, eps)
if apierrors.IsNotFound(err) {
logger.Debugf("EndpointSlice not found")
return reconcile.Result{}, nil
}
if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get EndpointSlice: %w", err)
}
if !eps.DeletionTimestamp.IsZero() {
logger.Debug("EndpointSlice is being deleted, clean up resources")
return reconcile.Result{}, dnsRR.maybeCleanup(ctx, eps, logger)
}
maybeHeadlessSvcName, ok := eps.Labels[discoveryv1.LabelServiceName]
if !ok {
logger.Debugf("EndpointSlice does not have %s label, do nothing", discoveryv1.LabelServiceName)
return reconcile.Result{}, nil
}
maybyHeadlessSvc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: maybeHeadlessSvcName, Namespace: dnsRR.tsNamespace}}
if err = dnsRR.Get(ctx, client.ObjectKeyFromObject(maybyHeadlessSvc), maybyHeadlessSvc); err != nil {
return reconcile.Result{}, fmt.Errorf("error retrieving Service for EndpointSlice: %w", err)
}
ok, err = dnsRR.isHeadlessSvcForSupportedProxy(ctx, maybyHeadlessSvc)
if err != nil {
return reconcile.Result{}, fmt.Errorf("error validating proxy for DNS records: %w", err)
}
if !ok {
logger.Debugf("EndpointSlice is not for a proxy type that we create DNS records for, do nothing")
return reconcile.Result{}, nil
}
dnsCfgLst := new(tsapi.DNSConfigList)
if err = dnsRR.List(ctx, dnsCfgLst); err != nil {
return reconcile.Result{}, fmt.Errorf("error listing DNSConfigs: %w", err)
}
if len(dnsCfgLst.Items) == 0 {
logger.Debugf("DNSConfig does not exist, not creating DNS records")
return reconcile.Result{}, nil
}
if len(dnsCfgLst.Items) > 1 {
logger.Errorf("Invalid cluster state - more than one DNSConfig found in cluster. Please ensure no more than one exists")
return reconcile.Result{}, nil
}
dnsCfg := dnsCfgLst.Items[0]
if !kube.DNSCfgIsReady(&dnsCfg) {
logger.Info("DNSConfig is not ready yet, waiting...")
return reconcile.Result{}, nil
}
return reconcile.Result{}, dnsRR.maybeProvision(ctx, eps, logger)
}
func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, eps *discoveryv1.EndpointSlice, logger *zap.SugaredLogger) error {
logger.Debugf("provisioning record")
if eps == nil {
return nil
}
fqdn, err := dnsRR.fqdnForDNSRecord(ctx, eps, logger)
if err != nil {
return fmt.Errorf("error determining DNS name for record: %w", err)
}
if fqdn == "" {
logger.Debugf("MagicDNS name does not (yet) exist, not provisioning DNS record")
return nil // a new reconcile will be triggered once it's added
}
oldEps := eps.DeepCopy()
if !slices.Contains(eps.Finalizers, dnsRecordsRecocilerFinalizer) {
eps.Finalizers = append(eps.Finalizers, dnsRecordsRecocilerFinalizer)
}
if _, ok := eps.Annotations[annotationTSMagicDNSName]; !ok {
mak.Set(&eps.Annotations, annotationTSMagicDNSName, fqdn) // label eps with the assocated MagicDNS name to make record cleanup easier
}
if !apiequality.Semantic.DeepEqual(oldEps, eps) {
logger.Infof("provisioning DNS record for MagicDNS name: %s", fqdn) // this will be printed exactly once
if err := dnsRR.Update(ctx, eps); err != nil {
return fmt.Errorf("error updating EndpointSlice metadata: %w", err)
}
}
ips := make([]string, 0)
for _, ep := range eps.Endpoints {
ips = append(ips, ep.Addresses...)
}
if len(ips) == 0 {
logger.Debugf("No endpoint addresses found")
return nil // a new reconcile will be triggered once the EndpointSlice is updated with addresses
}
updateFunc := func(cfg *operatorutils.TSHosts) {
mak.Set(&cfg.Hosts, fqdn, ips)
}
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error updating DNS records: %w", err)
}
return nil
}
func (h *dnsRecordsReconciler) maybeCleanup(ctx context.Context, eps *discoveryv1.EndpointSlice, logger *zap.SugaredLogger) error {
ix := slices.Index(eps.Finalizers, dnsRecordsRecocilerFinalizer)
if ix == -1 {
logger.Debugf("no finalizer, nothing to do")
return nil
}
cm := &corev1.ConfigMap{}
err := h.Client.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: h.tsNamespace}, cm)
if apierrors.IsNotFound(err) { // If the ConfigMap with the DNS config does not exist, just remove the finalizer
logger.Debug("CM not found")
return h.removeEPSFinalizer(ctx, eps)
}
if err != nil {
return fmt.Errorf("error retrieving ConfigMap: %w", err)
}
_, ok := cm.Data[dnsConfigKey]
if !ok {
logger.Debug("config key not found")
return h.removeEPSFinalizer(ctx, eps)
}
fqdn, ok := eps.GetAnnotations()[annotationTSMagicDNSName]
if !ok || fqdn == "" {
return h.removeEPSFinalizer(ctx, eps)
}
logger.Infof("removing DNS record for MagicDNS name %s", fqdn)
updateFunc := func(cfg *operatorutils.TSHosts) {
delete(cfg.Hosts, fqdn)
}
if err = h.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error updating DNS config: %w", err)
}
return h.removeEPSFinalizer(ctx, eps)
}
func (dnsRR *dnsRecordsReconciler) isHeadlessSvcForSupportedProxy(ctx context.Context, svc *corev1.Service) (bool, error) {
if isManagedByType(svc, "ingress") {
return true, nil
}
if !isManagedByType(svc, "svc") {
return false, nil
}
parentNSName := parentFromObjectLabels(svc)
parentSvc := new(corev1.Service)
if err := dnsRR.Get(ctx, parentNSName, parentSvc); err != nil {
return false, fmt.Errorf("error retrieving parent Service: %w", err)
}
if ip := tailnetTargetAnnotation(parentSvc); ip != "" {
return true, nil // egress Service
}
if _, ok := parentSvc.GetAnnotations()[AnnotationTailnetTargetFQDN]; ok {
return true, nil // egress Service
}
return false, nil // ingress Service
}
func (dnsRR *dnsRecordsReconciler) removeEPSFinalizer(ctx context.Context, eps *discoveryv1.EndpointSlice) error {
idx := slices.Index(eps.Finalizers, dnsRecordsRecocilerFinalizer)
if idx == -1 {
return nil
}
eps.Finalizers = append(eps.Finalizers[:idx], eps.Finalizers[idx+1:]...)
return dnsRR.Update(ctx, eps)
}
func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, eps *discoveryv1.EndpointSlice, logger *zap.SugaredLogger) (string, error) {
svcName, ok := eps.Labels[discoveryv1.LabelServiceName] // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
if !ok {
logger.Debugf("EndpointSlice is not managed by a Service")
return "", nil
}
maybeHeadlessSvc := new(corev1.Service)
if err := dnsRR.Get(ctx, types.NamespacedName{Namespace: dnsRR.tsNamespace, Name: svcName}, maybeHeadlessSvc); err != nil {
return "", fmt.Errorf("error retrieving owning Service for EndpointSlice: %w", err)
}
parentName := parentFromObjectLabels(maybeHeadlessSvc)
if isManagedByType(maybeHeadlessSvc, "ingress") {
ing := new(networkingv1.Ingress)
if err := dnsRR.Get(ctx, parentName, ing); err != nil {
return "", err
}
if len(ing.Status.LoadBalancer.Ingress) == 0 {
return "", nil
}
return ing.Status.LoadBalancer.Ingress[0].Hostname, nil
}
if isManagedByType(maybeHeadlessSvc, "svc") {
svc := new(corev1.Service)
if err := dnsRR.Get(ctx, parentName, svc); err != nil {
return "", err
}
return dnsRR.fqdnForDNSRecordFromService(ctx, svc)
}
return "", nil
}
func (h *dnsRecordsReconciler) updateDNSConfig(ctx context.Context, update func(*operatorutils.TSHosts)) error {
cm := &corev1.ConfigMap{}
if err := h.Client.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: h.tsNamespace}, cm); err != nil {
return fmt.Errorf("error retrieving nameserver config: %w", err)
}
dnsCfg := operatorutils.TSHosts{Hosts: make(map[string][]string)}
if cm.Data != nil && cm.Data[dnsConfigKey] != "" {
if err := json.Unmarshal([]byte(cm.Data[dnsConfigKey]), &dnsCfg); err != nil {
return err
}
}
update(&dnsCfg)
configBytes, err := json.Marshal(dnsCfg)
if err != nil {
return fmt.Errorf("error marshalling DNS config: %w", err)
}
mak.Set(&cm.Data, dnsConfigKey, string(configBytes))
return h.Update(ctx, cm)
}
func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecordFromService(ctx context.Context, svc *corev1.Service) (string, error) {
if tailnetIP := tailnetTargetAnnotation(svc); tailnetIP != "" {
return dnsRR.tailnetFQDNForIP(ctx, tailnetIP)
}
if tailnetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]; tailnetFQDN != "" {
return tailnetFQDN, nil
}
if hasLoadBalancerClass(svc, dnsRR.isDefaultLoadBalancer) {
if len(svc.Status.LoadBalancer.Ingress) > 0 {
return svc.Status.LoadBalancer.Ingress[0].Hostname, nil
}
return "", nil
}
if hasExposeAnnotation(svc) {
return dnsRR.fqdnFromSecretData(ctx, svc)
}
return "", nil
}
func (h *dnsRecordsReconciler) tailnetFQDNForIP(ctx context.Context, ip string) (string, error) {
whois, err := h.localAPIClient.WhoIs(ctx, ip)
if err != nil {
h.logger.Errorf("error determining Tailscale node: %v", err)
return "", err
}
fqdn := whois.Node.Name
fqdn = strings.TrimSuffix(fqdn, ".")
return fqdn, nil
}
func (h *dnsRecordsReconciler) fqdnFromSecretData(ctx context.Context, svc *corev1.Service) (string, error) {
childResourceLabels := map[string]string{
LabelManaged: "true",
LabelParentName: svc.Name,
LabelParentNamespace: svc.Namespace,
LabelParentType: "svc",
}
secret, err := getSingleObject[corev1.Secret](ctx, h.Client, h.tsNamespace, childResourceLabels)
if err != nil {
return "", err
}
return string(secret.Data["device_fqdn"]), nil
}