mirror of
				https://github.com/tailscale/tailscale.git
				synced 2025-10-31 16:22:03 +01:00 
			
		
		
		
	Adds a new reconciler for ProxyGroups of type kube-apiserver that will provision a Tailscale Service for each replica to advertise. Adds two new condition types to the ProxyGroup, TailscaleServiceValid and TailscaleServiceConfigured, to post updates on the state of that reconciler in a way that's consistent with the service-pg reconciler. The created Tailscale Service name is configurable via a new ProxyGroup field spec.kubeAPISserver.ServiceName, which expects a string of the form "svc:<dns-label>". Lots of supporting changes were needed to implement this in a way that's consistent with other operator workflows, including: * Pulled containerboot's ensureServicesUnadvertised and certManager into kube/ libraries to be shared with k8s-proxy. Use those in k8s-proxy to aid Service cert sharing between replicas and graceful Service shutdown. * For certManager, add an initial wait to the cert loop to wait until the domain appears in the devices's netmap to avoid a guaranteed error on the first issue attempt when it's quick to start. * Made several methods in ingress-for-pg.go and svc-for-pg.go into functions to share with the new reconciler * Added a Resource struct to the owner refs stored in Tailscale Service annotations to be able to distinguish between Ingress- and ProxyGroup- based Services that need cleaning up in the Tailscale API. * Added a ListVIPServices method to the internal tailscale client to aid cleaning up orphaned Services * Support for reading config from a kube Secret, and partial support for config reloading, to prevent us having to force Pod restarts when config changes. * Fixed up the zap logger so it's possible to set debug log level. Updates #13358 Change-Id: Ia9607441157dd91fb9b6ecbc318eecbef446e116 Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
		
			
				
	
	
		
			172 lines
		
	
	
		
			5.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			172 lines
		
	
	
		
			5.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright (c) Tailscale Inc & AUTHORS
 | |
| // SPDX-License-Identifier: BSD-3-Clause
 | |
| 
 | |
| //go:build linux
 | |
| 
 | |
| package main
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"log"
 | |
| 	"os"
 | |
| 	"path/filepath"
 | |
| 	"reflect"
 | |
| 	"sync/atomic"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/fsnotify/fsnotify"
 | |
| 	"tailscale.com/client/local"
 | |
| 	"tailscale.com/ipn"
 | |
| 	"tailscale.com/kube/certs"
 | |
| 	"tailscale.com/kube/kubetypes"
 | |
| 	klc "tailscale.com/kube/localclient"
 | |
| 	"tailscale.com/types/netmap"
 | |
| )
 | |
| 
 | |
| // watchServeConfigChanges watches path for changes, and when it sees one, reads
 | |
| // the serve config from it, replacing ${TS_CERT_DOMAIN} with certDomain, and
 | |
| // 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, 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 {
 | |
| 		// Creating a new fsnotify watcher would fail for example if inotify was not able to create a new file descriptor.
 | |
| 		// See https://github.com/tailscale/tailscale/issues/15081
 | |
| 		log.Printf("serve proxy: failed to create fsnotify watcher, timer-only mode: %v", err)
 | |
| 		ticker := time.NewTicker(5 * time.Second)
 | |
| 		defer ticker.Stop()
 | |
| 		tickChan = ticker.C
 | |
| 	} else {
 | |
| 		defer w.Close()
 | |
| 		if err := w.Add(filepath.Dir(cfg.ServeConfigPath)); err != nil {
 | |
| 			log.Fatalf("serve proxy: failed to add fsnotify watch: %v", err)
 | |
| 		}
 | |
| 		eventChan = w.Events
 | |
| 	}
 | |
| 
 | |
| 	var certDomain string
 | |
| 	var prevServeConfig *ipn.ServeConfig
 | |
| 	var cm *certs.CertManager
 | |
| 	if cfg.CertShareMode == "rw" {
 | |
| 		cm = certs.NewCertManager(klc.New(lc), log.Printf)
 | |
| 	}
 | |
| 	for {
 | |
| 		select {
 | |
| 		case <-ctx.Done():
 | |
| 			return
 | |
| 		case <-cdChanged:
 | |
| 			certDomain = *certDomainAtomic.Load()
 | |
| 		case <-tickChan:
 | |
| 		case <-eventChan:
 | |
| 			// We can't do any reasonable filtering on the event because of how
 | |
| 			// k8s handles these mounts. So just re-read the file and apply it
 | |
| 			// if it's changed.
 | |
| 		}
 | |
| 		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", cfg.ServeConfigPath)
 | |
| 			continue
 | |
| 		}
 | |
| 		if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
 | |
| 			continue
 | |
| 		}
 | |
| 		if err := updateServeConfig(ctx, sc, certDomain, lc); err != nil {
 | |
| 			log.Fatalf("serve proxy: error updating serve config: %v", err)
 | |
| 		}
 | |
| 		if kc != nil && kc.canPatch {
 | |
| 			if err := kc.storeHTTPSEndpoint(ctx, certDomain); err != nil {
 | |
| 				log.Fatalf("serve proxy: error storing HTTPS endpoint: %v", err)
 | |
| 			}
 | |
| 		}
 | |
| 		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)
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func certDomainFromNetmap(nm *netmap.NetworkMap) string {
 | |
| 	if len(nm.DNS.CertDomains) == 0 {
 | |
| 		return ""
 | |
| 	}
 | |
| 	return nm.DNS.CertDomains[0]
 | |
| }
 | |
| 
 | |
| // 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 {
 | |
| 	if !isValidHTTPSConfig(certDomain, sc) {
 | |
| 		return nil
 | |
| 	}
 | |
| 	log.Printf("serve proxy: applying serve config")
 | |
| 	return lc.SetServeConfig(ctx, sc)
 | |
| }
 | |
| 
 | |
| func isValidHTTPSConfig(certDomain string, sc *ipn.ServeConfig) bool {
 | |
| 	if certDomain == kubetypes.ValueNoHTTPS && hasHTTPSEndpoint(sc) {
 | |
| 		log.Printf(
 | |
| 			`serve proxy: this node is configured as a proxy that exposes an HTTPS endpoint to tailnet,
 | |
| 		(perhaps a Kubernetes operator Ingress proxy) but it is not able to issue TLS certs, so this will likely not work.
 | |
| 		To make it work, ensure that HTTPS is enabled for your tailnet, see https://tailscale.com/kb/1153/enabling-https for more details.`)
 | |
| 		return false
 | |
| 	}
 | |
| 	return true
 | |
| }
 | |
| 
 | |
| func hasHTTPSEndpoint(cfg *ipn.ServeConfig) bool {
 | |
| 	if cfg == nil {
 | |
| 		return false
 | |
| 	}
 | |
| 	for _, tcpCfg := range cfg.TCP {
 | |
| 		if tcpCfg.HTTPS {
 | |
| 			return true
 | |
| 		}
 | |
| 	}
 | |
| 	return false
 | |
| }
 | |
| 
 | |
| // readServeConfig reads the ipn.ServeConfig from path, replacing
 | |
| // ${TS_CERT_DOMAIN} with certDomain.
 | |
| func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
 | |
| 	if path == "" {
 | |
| 		return nil, nil
 | |
| 	}
 | |
| 	j, err := os.ReadFile(path)
 | |
| 	if err != nil {
 | |
| 		if os.IsNotExist(err) {
 | |
| 			return nil, nil
 | |
| 		}
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	// Serve config can be provided by users as well as the Kubernetes Operator (for its proxies). User-provided
 | |
| 	// config could be empty for reasons.
 | |
| 	if len(j) == 0 {
 | |
| 		log.Printf("serve proxy: serve config file is empty, skipping")
 | |
| 		return nil, nil
 | |
| 	}
 | |
| 	j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain))
 | |
| 	var sc ipn.ServeConfig
 | |
| 	if err := json.Unmarshal(j, &sc); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	return &sc, nil
 | |
| }
 |