mirror of https://github.com/tailscale/tailscale/
cmd/{k8s-nameserver,k8s-operator},k8s-operator: add a kube nameserver, make operator deploy it (#11919)
* cmd/k8s-nameserver,k8s-operator: add a nameserver that can resolve ts.net DNS names in cluster. Adds a simple nameserver that can respond to A record queries for ts.net DNS names. It can respond to queries from in-memory records, populated from a ConfigMap mounted at /config. It dynamically updates its records as the ConfigMap contents changes. It will respond with NXDOMAIN to queries for any other record types (AAAA to be implemented in the future). It can respond to queries over UDP or TCP. It runs a miekg/dns DNS server with a single registered handler for ts.net domain names. Queries for other domain names will be refused. The intended use of this is: 1) to allow non-tailnet cluster workloads to talk to HTTPS tailnet services exposed via Tailscale operator egress over HTTPS 2) to allow non-tailnet cluster workloads to talk to workloads in the same cluster that have been exposed to tailnet over their MagicDNS names but on their cluster IPs. DNSConfig CRD can be used to configure the operator to deploy kube nameserver (./cmd/k8s-nameserver) to cluster. Updates tailscale/tailscale#10499 Signed-off-by: Irbe Krumina <irbe@tailscale.com>pull/11944/head
parent
1fe073098c
commit
44aa809cb0
@ -0,0 +1,355 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
// k8s-nameserver is a simple nameserver implementation meant to be used with
|
||||||
|
// k8s-operator to allow to resolve magicDNS names associated with tailnet
|
||||||
|
// proxies in cluster.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
operatorutils "tailscale.com/k8s-operator"
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// tsNetDomain is the domain that this DNS nameserver has registered a handler for.
|
||||||
|
tsNetDomain = "ts.net"
|
||||||
|
// addr is the the address that the UDP and TCP listeners will listen on.
|
||||||
|
addr = ":1053"
|
||||||
|
|
||||||
|
// The following constants are specific to the nameserver configuration
|
||||||
|
// provided by a mounted Kubernetes Configmap. The Configmap mounted at
|
||||||
|
// /config is the only supported way for configuring this nameserver.
|
||||||
|
defaultDNSConfigDir = "/config"
|
||||||
|
defaultDNSFile = "dns.json"
|
||||||
|
kubeletMountedConfigLn = "..data"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nameserver is a simple nameserver that responds to DNS queries for A records
|
||||||
|
// for ts.net domain names over UDP or TCP. It serves DNS responses from
|
||||||
|
// in-memory IPv4 host records. It is intended to be deployed on Kubernetes with
|
||||||
|
// a ConfigMap mounted at /config that should contain the host records. It
|
||||||
|
// dynamically reconfigures its in-memory mappings as the contents of the
|
||||||
|
// mounted ConfigMap changes.
|
||||||
|
type nameserver struct {
|
||||||
|
// configReader returns the latest desired configuration (host records)
|
||||||
|
// for the nameserver. By default it gets set to a reader that reads
|
||||||
|
// from a Kubernetes ConfigMap mounted at /config, but this can be
|
||||||
|
// overridden in tests.
|
||||||
|
configReader configReaderFunc
|
||||||
|
// configWatcher is a watcher that returns an event when the desired
|
||||||
|
// configuration has changed and the nameserver should update the
|
||||||
|
// in-memory records.
|
||||||
|
configWatcher <-chan string
|
||||||
|
|
||||||
|
mu sync.Mutex // protects following
|
||||||
|
// ip4 are the in-memory hostname -> IP4 mappings that the nameserver
|
||||||
|
// uses to respond to A record queries.
|
||||||
|
ip4 map[dnsname.FQDN][]net.IP
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Ensure that we watch the kube Configmap mounted at /config for
|
||||||
|
// nameserver configuration updates and send events when updates happen.
|
||||||
|
c := ensureWatcherForKubeConfigMap(ctx)
|
||||||
|
|
||||||
|
ns := &nameserver{
|
||||||
|
configReader: configMapConfigReader,
|
||||||
|
configWatcher: c,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that in-memory records get set up to date now and will get
|
||||||
|
// reset when the configuration changes.
|
||||||
|
ns.runRecordsReconciler(ctx)
|
||||||
|
|
||||||
|
// Register a DNS server handle for ts.net domain names. Not having a
|
||||||
|
// handle registered for any other domain names is how we enforce that
|
||||||
|
// this nameserver can only be used for ts.net domains - querying any
|
||||||
|
// other domain names returns Rcode Refused.
|
||||||
|
dns.HandleFunc(tsNetDomain, ns.handleFunc())
|
||||||
|
|
||||||
|
// Listen for DNS queries over UDP and TCP.
|
||||||
|
udpSig := make(chan os.Signal)
|
||||||
|
tcpSig := make(chan os.Signal)
|
||||||
|
go listenAndServe("udp", addr, udpSig)
|
||||||
|
go listenAndServe("tcp", addr, tcpSig)
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
s := <-sig
|
||||||
|
log.Printf("OS signal (%s) received, shutting down", s)
|
||||||
|
cancel() // exit the records reconciler and configmap watcher goroutines
|
||||||
|
udpSig <- s // stop the UDP listener
|
||||||
|
tcpSig <- s // stop the TCP listener
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFunc is a DNS query handler that can respond to A record queries from
|
||||||
|
// the nameserver's in-memory records.
|
||||||
|
// - If an A record query is received and the
|
||||||
|
// nameserver's in-memory records contain records for the queried domain name,
|
||||||
|
// return a success response.
|
||||||
|
// - If an A record query is received, but the
|
||||||
|
// nameserver's in-memory records do not contain records for the queried domain name,
|
||||||
|
// return NXDOMAIN.
|
||||||
|
// - If an A record query is received, but the queried domain name is not valid, return Format Error.
|
||||||
|
// - If a query is received for any other record type than A, return Not Implemented.
|
||||||
|
func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
h := func(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
defer func() {
|
||||||
|
w.WriteMsg(m)
|
||||||
|
}()
|
||||||
|
if len(r.Question) < 1 {
|
||||||
|
log.Print("[unexpected] nameserver received a request with no questions")
|
||||||
|
m = r.SetRcodeFormatError(r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO (irbekrm): maybe set message compression
|
||||||
|
switch r.Question[0].Qtype {
|
||||||
|
case dns.TypeA:
|
||||||
|
q := r.Question[0].Name
|
||||||
|
fqdn, err := dnsname.ToFQDN(q)
|
||||||
|
if err != nil {
|
||||||
|
m = r.SetRcodeFormatError(r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The only supported use of this nameserver is as a
|
||||||
|
// single source of truth for MagicDNS names by
|
||||||
|
// non-tailnet Kubernetes workloads.
|
||||||
|
m.Authoritative = true
|
||||||
|
m.RecursionAvailable = false
|
||||||
|
|
||||||
|
ips := n.lookupIP4(fqdn)
|
||||||
|
if ips == nil || len(ips) == 0 {
|
||||||
|
// As we are the authoritative nameserver for MagicDNS
|
||||||
|
// names, if we do not have a record for this MagicDNS
|
||||||
|
// name, it does not exist.
|
||||||
|
m = m.SetRcode(r, dns.RcodeNameError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO (irbekrm): TTL is currently set to 0, meaning
|
||||||
|
// that cluster workloads will not cache the DNS
|
||||||
|
// records. Revisit this in future when we understand
|
||||||
|
// the usage patterns better- is it putting too much
|
||||||
|
// load on kube DNS server or is this fine?
|
||||||
|
for _, ip := range ips {
|
||||||
|
rr := &dns.A{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, A: ip}
|
||||||
|
m.SetRcode(r, dns.RcodeSuccess)
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
}
|
||||||
|
case dns.TypeAAAA:
|
||||||
|
// TODO (irbekrm): implement IPv6 support.
|
||||||
|
// Kubernetes distributions that I am most familiar with
|
||||||
|
// default to IPv4 for Pod CIDR ranges and often many cases don't
|
||||||
|
// support IPv6 at all, so this should not be crucial for now.
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", r.Question[0].String())
|
||||||
|
m.SetRcode(r, dns.RcodeNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRecordsReconciler ensures that nameserver's in-memory records are
|
||||||
|
// reset when the provided configuration changes.
|
||||||
|
func (n *nameserver) runRecordsReconciler(ctx context.Context) {
|
||||||
|
log.Print("updating nameserver's records from the provided configuration...")
|
||||||
|
if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts
|
||||||
|
log.Fatalf("error setting nameserver's records: %v", err)
|
||||||
|
}
|
||||||
|
log.Print("nameserver's records were updated")
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("context cancelled, exiting records reconciler")
|
||||||
|
return
|
||||||
|
case <-n.configWatcher:
|
||||||
|
log.Print("configuration update detected, resetting records")
|
||||||
|
if err := n.resetRecords(); err != nil {
|
||||||
|
// TODO (irbekrm): this runs in a
|
||||||
|
// container that will be thrown away,
|
||||||
|
// so this should be ok. But maybe still
|
||||||
|
// need to ensure that the DNS server
|
||||||
|
// terminates connections more
|
||||||
|
// gracefully.
|
||||||
|
log.Fatalf("error resetting records: %v", err)
|
||||||
|
}
|
||||||
|
log.Print("nameserver records were reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetRecords sets the in-memory DNS records of this nameserver from the
|
||||||
|
// provided configuration. It does not check for the diff, so the caller is
|
||||||
|
// expected to ensure that this is only called when reset is needed.
|
||||||
|
func (n *nameserver) resetRecords() error {
|
||||||
|
dnsCfgBytes, err := n.configReader()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error reading nameserver's configuration: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 {
|
||||||
|
log.Print("nameserver's configuration is empty, any in-memory records will be unset")
|
||||||
|
n.mu.Lock()
|
||||||
|
n.ip4 = make(map[dnsname.FQDN][]net.IP)
|
||||||
|
n.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dnsCfg := &operatorutils.Records{}
|
||||||
|
err = json.Unmarshal(dnsCfgBytes, dnsCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error unmarshalling nameserver configuration: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dnsCfg.Version != operatorutils.Alpha1Version {
|
||||||
|
return fmt.Errorf("unsupported configuration version %s, supported versions are %s\n", dnsCfg.Version, operatorutils.Alpha1Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
ip4 := make(map[dnsname.FQDN][]net.IP)
|
||||||
|
defer func() {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
n.ip4 = ip4
|
||||||
|
}()
|
||||||
|
|
||||||
|
if len(dnsCfg.IP4) == 0 {
|
||||||
|
log.Print("nameserver's configuration contains no records, any in-memory records will be unset")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for fqdn, ips := range dnsCfg.IP4 {
|
||||||
|
fqdn, err := dnsname.ToFQDN(fqdn)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err)
|
||||||
|
continue // one invalid hostname should not break the whole nameserver
|
||||||
|
}
|
||||||
|
for _, ipS := range ips {
|
||||||
|
ip := net.ParseIP(ipS).To4()
|
||||||
|
if ip == nil { // To4 returns nil if IP is not a IPv4 address
|
||||||
|
log.Printf("invalid nameserver's configuration: %v does not appear to be an IPv4 address; skipping this record", ipS)
|
||||||
|
continue // one invalid IP address should not break the whole nameserver
|
||||||
|
}
|
||||||
|
ip4[fqdn] = []net.IP{ip}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenAndServe starts a DNS server for the provided network and address.
|
||||||
|
func listenAndServe(net, addr string, shutdown chan os.Signal) {
|
||||||
|
s := &dns.Server{Addr: addr, Net: net}
|
||||||
|
go func() {
|
||||||
|
<-shutdown
|
||||||
|
log.Printf("shutting down server for %s", net)
|
||||||
|
s.Shutdown()
|
||||||
|
}()
|
||||||
|
log.Printf("listening for %s queries on %s", net, addr)
|
||||||
|
if err := s.ListenAndServe(); err != nil {
|
||||||
|
log.Fatalf("error running %s server: %v", net, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureWatcherForKubeConfigMap sets up a new file watcher for the ConfigMap
|
||||||
|
// that's expected to be mounted at /config. Returns a channel that receives an
|
||||||
|
// event every time the contents get updated.
|
||||||
|
func ensureWatcherForKubeConfigMap(ctx context.Context) chan string {
|
||||||
|
c := make(chan string)
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error creating a new watcher for the mounted ConfigMap: %v", err)
|
||||||
|
}
|
||||||
|
// kubelet mounts configmap to a Pod using a series of symlinks, one of
|
||||||
|
// which is <mount-dir>/..data that Kubernetes recommends consumers to
|
||||||
|
// use if they need to monitor changes
|
||||||
|
// https://github.com/kubernetes/kubernetes/blob/v1.28.1/pkg/volume/util/atomic_writer.go#L39-L61
|
||||||
|
toWatch := filepath.Join(defaultDNSConfigDir, kubeletMountedConfigLn)
|
||||||
|
go func() {
|
||||||
|
defer watcher.Close()
|
||||||
|
log.Printf("starting file watch for %s", defaultDNSConfigDir)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Print("context cancelled, exiting ConfigMap watcher")
|
||||||
|
return
|
||||||
|
case event, ok := <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
log.Fatal("watcher finished; exiting")
|
||||||
|
}
|
||||||
|
if event.Name == toWatch {
|
||||||
|
msg := fmt.Sprintf("ConfigMap update received: %s", event)
|
||||||
|
log.Print(msg)
|
||||||
|
c <- msg
|
||||||
|
}
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if err != nil {
|
||||||
|
// TODO (irbekrm): this runs in a
|
||||||
|
// container that will be thrown away,
|
||||||
|
// so this should be ok. But maybe still
|
||||||
|
// need to ensure that the DNS server
|
||||||
|
// terminates connections more
|
||||||
|
// gracefully.
|
||||||
|
log.Fatalf("[unexpected] error watching configuration: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// TODO (irbekrm): this runs in a
|
||||||
|
// container that will be thrown away,
|
||||||
|
// so this should be ok. But maybe still
|
||||||
|
// need to ensure that the DNS server
|
||||||
|
// terminates connections more
|
||||||
|
// gracefully.
|
||||||
|
log.Fatalf("[unexpected] errors watcher exited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err = watcher.Add(defaultDNSConfigDir); err != nil {
|
||||||
|
log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// configReaderFunc is a function that returns the desired nameserver configuration.
|
||||||
|
type configReaderFunc func() ([]byte, error)
|
||||||
|
|
||||||
|
// configMapConfigReader reads the desired nameserver configuration from a
|
||||||
|
// dns.json file in a ConfigMap mounted at /config.
|
||||||
|
var configMapConfigReader configReaderFunc = func() ([]byte, error) {
|
||||||
|
if contents, err := os.ReadFile(filepath.Join(defaultDNSConfigDir, defaultDNSFile)); err == nil {
|
||||||
|
return contents, nil
|
||||||
|
} else if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupIP4 returns any IPv4 addresses for the given FQDN from nameserver's
|
||||||
|
// in-memory records.
|
||||||
|
func (n *nameserver) lookupIP4(fqdn dnsname.FQDN) []net.IP {
|
||||||
|
if n.ip4 == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
f := n.ip4[fqdn]
|
||||||
|
return f
|
||||||
|
}
|
@ -0,0 +1,227 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNameserver(t *testing.T) {
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip4 map[dnsname.FQDN][]net.IP
|
||||||
|
query *dns.Msg
|
||||||
|
wantResp *dns.Msg
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "A record query, record exists",
|
||||||
|
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||||
|
query: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}},
|
||||||
|
MsgHdr: dns.MsgHdr{Id: 1, RecursionDesired: true},
|
||||||
|
},
|
||||||
|
wantResp: &dns.Msg{
|
||||||
|
Answer: []dns.RR{&dns.A{Hdr: dns.RR_Header{
|
||||||
|
Name: "foo.bar.com", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0},
|
||||||
|
A: net.IP{1, 2, 3, 4}}},
|
||||||
|
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}},
|
||||||
|
MsgHdr: dns.MsgHdr{
|
||||||
|
Id: 1,
|
||||||
|
Rcode: dns.RcodeSuccess,
|
||||||
|
RecursionAvailable: false,
|
||||||
|
RecursionDesired: true,
|
||||||
|
Response: true,
|
||||||
|
Opcode: dns.OpcodeQuery,
|
||||||
|
Authoritative: true,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "A record query, record does not exist",
|
||||||
|
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||||
|
query: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}},
|
||||||
|
MsgHdr: dns.MsgHdr{Id: 1},
|
||||||
|
},
|
||||||
|
wantResp: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}},
|
||||||
|
MsgHdr: dns.MsgHdr{
|
||||||
|
Id: 1,
|
||||||
|
Rcode: dns.RcodeNameError,
|
||||||
|
RecursionAvailable: false,
|
||||||
|
Response: true,
|
||||||
|
Opcode: dns.OpcodeQuery,
|
||||||
|
Authoritative: true,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "A record query, but the name is not a valid FQDN",
|
||||||
|
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||||
|
query: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}},
|
||||||
|
MsgHdr: dns.MsgHdr{Id: 1},
|
||||||
|
},
|
||||||
|
wantResp: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}},
|
||||||
|
MsgHdr: dns.MsgHdr{
|
||||||
|
Id: 1,
|
||||||
|
Rcode: dns.RcodeFormatError,
|
||||||
|
Response: true,
|
||||||
|
Opcode: dns.OpcodeQuery,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AAAA record query",
|
||||||
|
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||||
|
query: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||||
|
MsgHdr: dns.MsgHdr{Id: 1},
|
||||||
|
},
|
||||||
|
wantResp: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||||
|
MsgHdr: dns.MsgHdr{
|
||||||
|
Id: 1,
|
||||||
|
Rcode: dns.RcodeNotImplemented,
|
||||||
|
Response: true,
|
||||||
|
Opcode: dns.OpcodeQuery,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AAAA record query",
|
||||||
|
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||||
|
query: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||||
|
MsgHdr: dns.MsgHdr{Id: 1},
|
||||||
|
},
|
||||||
|
wantResp: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||||
|
MsgHdr: dns.MsgHdr{
|
||||||
|
Id: 1,
|
||||||
|
Rcode: dns.RcodeNotImplemented,
|
||||||
|
Response: true,
|
||||||
|
Opcode: dns.OpcodeQuery,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CNAME record query",
|
||||||
|
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||||
|
query: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}},
|
||||||
|
MsgHdr: dns.MsgHdr{Id: 1},
|
||||||
|
},
|
||||||
|
wantResp: &dns.Msg{
|
||||||
|
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}},
|
||||||
|
MsgHdr: dns.MsgHdr{
|
||||||
|
Id: 1,
|
||||||
|
Rcode: dns.RcodeNotImplemented,
|
||||||
|
Response: true,
|
||||||
|
Opcode: dns.OpcodeQuery,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ns := &nameserver{
|
||||||
|
ip4: tt.ip4,
|
||||||
|
}
|
||||||
|
handler := ns.handleFunc()
|
||||||
|
fakeRespW := &fakeResponseWriter{}
|
||||||
|
handler(fakeRespW, tt.query)
|
||||||
|
if diff := cmp.Diff(*fakeRespW.msg, *tt.wantResp); diff != "" {
|
||||||
|
t.Fatalf("unexpected response (-got +want): \n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetRecords(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config []byte
|
||||||
|
hasIp4 map[dnsname.FQDN][]net.IP
|
||||||
|
wantsIp4 map[dnsname.FQDN][]net.IP
|
||||||
|
wantsErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "previously empty nameserver.ip4 gets set",
|
||||||
|
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
|
||||||
|
wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nameserver.ip4 gets reset",
|
||||||
|
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||||
|
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
|
||||||
|
wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "configuration with incompatible version",
|
||||||
|
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||||
|
config: []byte(`{"version": "v1beta1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
|
||||||
|
wantsIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||||
|
wantsErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nameserver.ip4 gets reset to empty config when no configuration is provided",
|
||||||
|
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||||
|
wantsIp4: make(map[dnsname.FQDN][]net.IP),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nameserver.ip4 gets reset to empty config when the provided configuration is empty",
|
||||||
|
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||||
|
config: []byte(`{"version": "v1alpha1", "ip4": {}}`),
|
||||||
|
wantsIp4: make(map[dnsname.FQDN][]net.IP),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ns := &nameserver{
|
||||||
|
ip4: tt.hasIp4,
|
||||||
|
configReader: func() ([]byte, error) { return tt.config, nil },
|
||||||
|
}
|
||||||
|
if err := ns.resetRecords(); err == nil == tt.wantsErr {
|
||||||
|
t.Errorf("resetRecords() returned err: %v, wantsErr: %v", err, tt.wantsErr)
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff(ns.ip4, tt.wantsIp4); diff != "" {
|
||||||
|
t.Fatalf("unexpected nameserver.ip4 contents (-got +want): \n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeResponseWriter is a faked out dns.ResponseWriter that can be used in
|
||||||
|
// tests that need to read the response message that was written.
|
||||||
|
type fakeResponseWriter struct {
|
||||||
|
msg *dns.Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ dns.ResponseWriter = &fakeResponseWriter{}
|
||||||
|
|
||||||
|
func (fr *fakeResponseWriter) WriteMsg(msg *dns.Msg) error {
|
||||||
|
fr.msg = msg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (fr *fakeResponseWriter) LocalAddr() net.Addr {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (fr *fakeResponseWriter) RemoteAddr() net.Addr {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (fr *fakeResponseWriter) Write([]byte) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (fr *fakeResponseWriter) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (fr *fakeResponseWriter) TsigStatus() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (fr *fakeResponseWriter) TsigTimersOnly(bool) {}
|
||||||
|
func (fr *fakeResponseWriter) Hijack() {}
|
@ -0,0 +1,96 @@
|
|||||||
|
apiVersion: apiextensions.k8s.io/v1
|
||||||
|
kind: CustomResourceDefinition
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
controller-gen.kubebuilder.io/version: v0.13.0
|
||||||
|
name: dnsconfigs.tailscale.com
|
||||||
|
spec:
|
||||||
|
group: tailscale.com
|
||||||
|
names:
|
||||||
|
kind: DNSConfig
|
||||||
|
listKind: DNSConfigList
|
||||||
|
plural: dnsconfigs
|
||||||
|
shortNames:
|
||||||
|
- dc
|
||||||
|
singular: dnsconfig
|
||||||
|
scope: Cluster
|
||||||
|
versions:
|
||||||
|
- additionalPrinterColumns:
|
||||||
|
- description: Service IP address of the nameserver
|
||||||
|
jsonPath: .status.nameserverStatus.ip
|
||||||
|
name: NameserverIP
|
||||||
|
type: string
|
||||||
|
name: v1alpha1
|
||||||
|
schema:
|
||||||
|
openAPIV3Schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- spec
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
spec:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- nameserver
|
||||||
|
properties:
|
||||||
|
nameserver:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
image:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
repo:
|
||||||
|
type: string
|
||||||
|
tag:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
conditions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
description: ConnectorCondition contains condition information for a Connector.
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- type
|
||||||
|
properties:
|
||||||
|
lastTransitionTime:
|
||||||
|
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
message:
|
||||||
|
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
reason:
|
||||||
|
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||||
|
type: string
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- type
|
||||||
|
x-kubernetes-list-type: map
|
||||||
|
nameserverStatus:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ip:
|
||||||
|
type: string
|
||||||
|
served: true
|
||||||
|
storage: true
|
||||||
|
subresources:
|
||||||
|
status: {}
|
@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: dnsconfig
|
@ -0,0 +1,37 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: nameserver
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
revisionHistoryLimit: 5
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: nameserver
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: nameserver
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- imagePullPolicy: IfNotPresent
|
||||||
|
name: nameserver
|
||||||
|
ports:
|
||||||
|
- name: tcp
|
||||||
|
protocol: TCP
|
||||||
|
containerPort: 1053
|
||||||
|
- name: udp
|
||||||
|
protocol: UDP
|
||||||
|
containerPort: 1053
|
||||||
|
volumeMounts:
|
||||||
|
- name: dnsconfig
|
||||||
|
mountPath: /config
|
||||||
|
restartPolicy: Always
|
||||||
|
serviceAccount: nameserver
|
||||||
|
serviceAccountName: nameserver
|
||||||
|
volumes:
|
||||||
|
- name: dnsconfig
|
||||||
|
configMap:
|
||||||
|
name: dnsconfig
|
@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: nameserver
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: foo
|
@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nameserver
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: nameserver
|
||||||
|
ports:
|
||||||
|
- name: udp
|
||||||
|
targetPort: 1053
|
||||||
|
port: 53
|
||||||
|
protocol: UDP
|
||||||
|
- name: tcp
|
||||||
|
targetPort: 1053
|
||||||
|
port: 53
|
||||||
|
protocol: TCP
|
@ -0,0 +1,275 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
xslices "golang.org/x/exp/slices"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/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"
|
||||||
|
"k8s.io/client-go/tools/record"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"sigs.k8s.io/yaml"
|
||||||
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/tstime"
|
||||||
|
"tailscale.com/util/clientmetric"
|
||||||
|
"tailscale.com/util/set"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
reasonNameserverCreationFailed = "NameserverCreationFailed"
|
||||||
|
reasonMultipleDNSConfigsPresent = "MultipleDNSConfigsPresent"
|
||||||
|
|
||||||
|
reasonNameserverCreated = "NameserverCreated"
|
||||||
|
|
||||||
|
messageNameserverCreationFailed = "Failed creating nameserver resources: %v"
|
||||||
|
messageMultipleDNSConfigsPresent = "Multiple DNSConfig resources found in cluster. Please ensure no more than one is present."
|
||||||
|
)
|
||||||
|
|
||||||
|
// NameserverReconciler knows how to create nameserver resources in cluster in
|
||||||
|
// response to users applying DNSConfig.
|
||||||
|
type NameserverReconciler struct {
|
||||||
|
client.Client
|
||||||
|
logger *zap.SugaredLogger
|
||||||
|
recorder record.EventRecorder
|
||||||
|
clock tstime.Clock
|
||||||
|
tsNamespace string
|
||||||
|
|
||||||
|
mu sync.Mutex // protects following
|
||||||
|
managedNameservers set.Slice[types.UID] // one or none
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
gaugeNameserverResources = clientmetric.NewGauge("k8s_nameserver_resources")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||||
|
logger := a.logger.With("dnsConfig", req.Name)
|
||||||
|
logger.Debugf("starting reconcile")
|
||||||
|
defer logger.Debugf("reconcile finished")
|
||||||
|
|
||||||
|
var dnsCfg tsapi.DNSConfig
|
||||||
|
err = a.Get(ctx, req.NamespacedName, &dnsCfg)
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
// Request object not found, could have been deleted after reconcile request.
|
||||||
|
logger.Debugf("dnsconfig not found, assuming it was deleted")
|
||||||
|
return reconcile.Result{}, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return reconcile.Result{}, fmt.Errorf("failed to get dnsconfig: %w", err)
|
||||||
|
}
|
||||||
|
if !dnsCfg.DeletionTimestamp.IsZero() {
|
||||||
|
ix := xslices.Index(dnsCfg.Finalizers, FinalizerName)
|
||||||
|
if ix < 0 {
|
||||||
|
logger.Debugf("no finalizer, nothing to do")
|
||||||
|
return reconcile.Result{}, nil
|
||||||
|
}
|
||||||
|
logger.Info("Cleaning up DNSConfig resources")
|
||||||
|
if err := a.maybeCleanup(ctx, &dnsCfg, logger); err != nil {
|
||||||
|
logger.Errorf("error cleaning up reconciler resource: %v", err)
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
dnsCfg.Finalizers = append(dnsCfg.Finalizers[:ix], dnsCfg.Finalizers[ix+1:]...)
|
||||||
|
if err := a.Update(ctx, &dnsCfg); err != nil {
|
||||||
|
logger.Errorf("error removing finalizer: %v", err)
|
||||||
|
return reconcile.Result{}, err
|
||||||
|
}
|
||||||
|
logger.Infof("Nameserver resources cleaned up")
|
||||||
|
return reconcile.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldCnStatus := dnsCfg.Status.DeepCopy()
|
||||||
|
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||||
|
tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger)
|
||||||
|
if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) {
|
||||||
|
// An error encountered here should get returned by the Reconcile function.
|
||||||
|
if updateErr := a.Client.Status().Update(ctx, dnsCfg); updateErr != nil {
|
||||||
|
err = errors.Wrap(err, updateErr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
var dnsCfgs tsapi.DNSConfigList
|
||||||
|
if err := a.List(ctx, &dnsCfgs); err != nil {
|
||||||
|
return res, fmt.Errorf("error listing DNSConfigs: %w", err)
|
||||||
|
}
|
||||||
|
if len(dnsCfgs.Items) > 1 { // enforce DNSConfig to be a singleton
|
||||||
|
msg := "invalid cluster configuration: more than one tailscale.com/dnsconfigs found. Please ensure that no more than one is created."
|
||||||
|
logger.Error(msg)
|
||||||
|
a.recorder.Event(&dnsCfg, corev1.EventTypeWarning, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
|
||||||
|
setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(dnsCfg.Finalizers, FinalizerName) {
|
||||||
|
logger.Infof("ensuring nameserver resources")
|
||||||
|
dnsCfg.Finalizers = append(dnsCfg.Finalizers, FinalizerName)
|
||||||
|
if err := a.Update(ctx, &dnsCfg); err != nil {
|
||||||
|
msg := fmt.Sprintf(messageNameserverCreationFailed, err)
|
||||||
|
logger.Error(msg)
|
||||||
|
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonNameserverCreationFailed, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.maybeProvision(ctx, &dnsCfg, logger); err != nil {
|
||||||
|
return reconcile.Result{}, fmt.Errorf("error provisioning nameserver resources: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.managedNameservers.Add(dnsCfg.UID)
|
||||||
|
a.mu.Unlock()
|
||||||
|
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
|
||||||
|
|
||||||
|
svc := &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: a.tsNamespace},
|
||||||
|
}
|
||||||
|
if err := a.Client.Get(ctx, client.ObjectKeyFromObject(svc), svc); err != nil {
|
||||||
|
return res, fmt.Errorf("error getting Service: %w", err)
|
||||||
|
}
|
||||||
|
if ip := svc.Spec.ClusterIP; ip != "" && ip != "None" {
|
||||||
|
dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{
|
||||||
|
IP: ip,
|
||||||
|
}
|
||||||
|
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated)
|
||||||
|
}
|
||||||
|
logger.Info("nameserver Service does not have an IP address allocated, waiting...")
|
||||||
|
return reconcile.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nameserverResourceLabels(name, namespace string) map[string]string {
|
||||||
|
labels := childResourceLabels(name, namespace, "nameserver")
|
||||||
|
labels["app.kubernetes.io/name"] = "tailscale"
|
||||||
|
labels["app.kubernetes.io/component"] = "nameserver"
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
|
||||||
|
labels := nameserverResourceLabels(tsDNSCfg.Name, a.tsNamespace)
|
||||||
|
dCfg := &deployConfig{
|
||||||
|
ownerRefs: []metav1.OwnerReference{*metav1.NewControllerRef(tsDNSCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))},
|
||||||
|
namespace: a.tsNamespace,
|
||||||
|
labels: labels,
|
||||||
|
}
|
||||||
|
if tsDNSCfg.Spec.Nameserver.Image.Repo != "" {
|
||||||
|
dCfg.imageRepo = tsDNSCfg.Spec.Nameserver.Image.Repo
|
||||||
|
}
|
||||||
|
if tsDNSCfg.Spec.Nameserver.Image.Tag != "" {
|
||||||
|
dCfg.imageTag = tsDNSCfg.Spec.Nameserver.Image.Tag
|
||||||
|
}
|
||||||
|
for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} {
|
||||||
|
if err := deployable.updateObj(ctx, dCfg, a.Client); err != nil {
|
||||||
|
return fmt.Errorf("error reconciling %s: %w", deployable.kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeCleanup removes DNSConfig from being tracked. The cluster resources
|
||||||
|
// created, will be automatically garbage collected as they are owned by the
|
||||||
|
// DNSConfig.
|
||||||
|
func (a *NameserverReconciler) maybeCleanup(ctx context.Context, dnsCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.managedNameservers.Remove(dnsCfg.UID)
|
||||||
|
a.mu.Unlock()
|
||||||
|
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type deployable struct {
|
||||||
|
kind string
|
||||||
|
updateObj func(context.Context, *deployConfig, client.Client) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type deployConfig struct {
|
||||||
|
imageRepo string
|
||||||
|
imageTag string
|
||||||
|
labels map[string]string
|
||||||
|
ownerRefs []metav1.OwnerReference
|
||||||
|
namespace string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed deploy/manifests/nameserver/cm.yaml
|
||||||
|
cmYaml []byte
|
||||||
|
//go:embed deploy/manifests/nameserver/deploy.yaml
|
||||||
|
deployYaml []byte
|
||||||
|
//go:embed deploy/manifests/nameserver/sa.yaml
|
||||||
|
saYaml []byte
|
||||||
|
//go:embed deploy/manifests/nameserver/svc.yaml
|
||||||
|
svcYaml []byte
|
||||||
|
|
||||||
|
deployDeployable = deployable{
|
||||||
|
kind: "Deployment",
|
||||||
|
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||||
|
d := new(appsv1.Deployment)
|
||||||
|
if err := yaml.Unmarshal(deployYaml, &d); err != nil {
|
||||||
|
return fmt.Errorf("error unmarshalling Deployment yaml: %w", err)
|
||||||
|
}
|
||||||
|
d.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("%s:%s", cfg.imageRepo, cfg.imageTag)
|
||||||
|
d.ObjectMeta.Namespace = cfg.namespace
|
||||||
|
d.ObjectMeta.Labels = cfg.labels
|
||||||
|
d.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||||
|
updateF := func(oldD *appsv1.Deployment) {
|
||||||
|
oldD.Spec = d.Spec
|
||||||
|
}
|
||||||
|
_, err := createOrUpdate[appsv1.Deployment](ctx, kubeClient, cfg.namespace, d, updateF)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
saDeployable = deployable{
|
||||||
|
kind: "ServiceAccount",
|
||||||
|
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||||
|
sa := new(corev1.ServiceAccount)
|
||||||
|
if err := yaml.Unmarshal(saYaml, &sa); err != nil {
|
||||||
|
return fmt.Errorf("error unmarshalling ServiceAccount yaml: %w", err)
|
||||||
|
}
|
||||||
|
sa.ObjectMeta.Labels = cfg.labels
|
||||||
|
sa.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||||
|
sa.ObjectMeta.Namespace = cfg.namespace
|
||||||
|
_, err := createOrUpdate(ctx, kubeClient, cfg.namespace, sa, func(*corev1.ServiceAccount) {})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svcDeployable = deployable{
|
||||||
|
kind: "Service",
|
||||||
|
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||||
|
svc := new(corev1.Service)
|
||||||
|
if err := yaml.Unmarshal(svcYaml, &svc); err != nil {
|
||||||
|
return fmt.Errorf("error unmarshalling Service yaml: %w", err)
|
||||||
|
}
|
||||||
|
svc.ObjectMeta.Labels = cfg.labels
|
||||||
|
svc.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||||
|
svc.ObjectMeta.Namespace = cfg.namespace
|
||||||
|
_, err := createOrUpdate[corev1.Service](ctx, kubeClient, cfg.namespace, svc, func(*corev1.Service) {})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmDeployable = deployable{
|
||||||
|
kind: "ConfigMap",
|
||||||
|
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||||
|
cm := new(corev1.ConfigMap)
|
||||||
|
if err := yaml.Unmarshal(cmYaml, &cm); err != nil {
|
||||||
|
return fmt.Errorf("error unmarshalling ConfigMap yaml: %w", err)
|
||||||
|
}
|
||||||
|
cm.ObjectMeta.Labels = cfg.labels
|
||||||
|
cm.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||||
|
cm.ObjectMeta.Namespace = cfg.namespace
|
||||||
|
_, err := createOrUpdate[corev1.ConfigMap](ctx, kubeClient, cfg.namespace, cm, func(cm *corev1.ConfigMap) {})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
@ -0,0 +1,118 @@
|
|||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
"sigs.k8s.io/yaml"
|
||||||
|
operatorutils "tailscale.com/k8s-operator"
|
||||||
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/tstest"
|
||||||
|
"tailscale.com/util/mak"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNameserverReconciler(t *testing.T) {
|
||||||
|
dnsCfg := &tsapi.DNSConfig{
|
||||||
|
TypeMeta: metav1.TypeMeta{Kind: "DNSConfig", APIVersion: "tailscale.com/v1alpha1"},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test",
|
||||||
|
},
|
||||||
|
Spec: tsapi.DNSConfigSpec{
|
||||||
|
Nameserver: &tsapi.Nameserver{
|
||||||
|
Image: &tsapi.Image{
|
||||||
|
Repo: "test",
|
||||||
|
Tag: "v0.0.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fc := fake.NewClientBuilder().
|
||||||
|
WithScheme(tsapi.GlobalScheme).
|
||||||
|
WithObjects(dnsCfg).
|
||||||
|
WithStatusSubresource(dnsCfg).
|
||||||
|
Build()
|
||||||
|
zl, err := zap.NewDevelopment()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||||
|
nr := &NameserverReconciler{
|
||||||
|
Client: fc,
|
||||||
|
clock: cl,
|
||||||
|
logger: zl.Sugar(),
|
||||||
|
tsNamespace: "tailscale",
|
||||||
|
}
|
||||||
|
expectReconciled(t, nr, "", "test")
|
||||||
|
// Verify that nameserver Deployment has been created and has the expected fields.
|
||||||
|
wantsDeploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: "tailscale"}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: appsv1.SchemeGroupVersion.Identifier()}}
|
||||||
|
if err := yaml.Unmarshal(deployYaml, wantsDeploy); err != nil {
|
||||||
|
t.Fatalf("unmarshalling yaml: %v", err)
|
||||||
|
}
|
||||||
|
dnsCfgOwnerRef := metav1.NewControllerRef(dnsCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))
|
||||||
|
wantsDeploy.OwnerReferences = []metav1.OwnerReference{*dnsCfgOwnerRef}
|
||||||
|
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.1"
|
||||||
|
wantsDeploy.Namespace = "tailscale"
|
||||||
|
labels := nameserverResourceLabels("test", "tailscale")
|
||||||
|
wantsDeploy.ObjectMeta.Labels = labels
|
||||||
|
expectEqual(t, fc, wantsDeploy, nil)
|
||||||
|
|
||||||
|
// Verify that DNSConfig advertizes the nameserver's Service IP address,
|
||||||
|
// has the ready status condition and tailscale finalizer.
|
||||||
|
mustUpdate(t, fc, "tailscale", "nameserver", func(svc *corev1.Service) {
|
||||||
|
svc.Spec.ClusterIP = "1.2.3.4"
|
||||||
|
})
|
||||||
|
expectReconciled(t, nr, "", "test")
|
||||||
|
dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{
|
||||||
|
IP: "1.2.3.4",
|
||||||
|
}
|
||||||
|
dnsCfg.Finalizers = []string{FinalizerName}
|
||||||
|
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, tsapi.ConnectorCondition{
|
||||||
|
Type: tsapi.NameserverReady,
|
||||||
|
Status: metav1.ConditionTrue,
|
||||||
|
Reason: reasonNameserverCreated,
|
||||||
|
Message: reasonNameserverCreated,
|
||||||
|
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||||
|
})
|
||||||
|
expectEqual(t, fc, dnsCfg, nil)
|
||||||
|
|
||||||
|
// // Verify that nameserver image gets updated to match DNSConfig spec.
|
||||||
|
mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) {
|
||||||
|
dnsCfg.Spec.Nameserver.Image.Tag = "v0.0.2"
|
||||||
|
})
|
||||||
|
expectReconciled(t, nr, "", "test")
|
||||||
|
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.2"
|
||||||
|
expectEqual(t, fc, wantsDeploy, nil)
|
||||||
|
|
||||||
|
// Verify that when another actor sets ConfigMap data, it does not get
|
||||||
|
// overwritten by nameserver reconciler.
|
||||||
|
dnsRecords := &operatorutils.Records{Version: "v1alpha1", IP4: map[string][]string{"foo.ts.net": {"1.2.3.4"}}}
|
||||||
|
bs, err := json.Marshal(dnsRecords)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error marshalling ConfigMap contents: %v", err)
|
||||||
|
}
|
||||||
|
mustUpdate(t, fc, "tailscale", "dnsconfig", func(cm *corev1.ConfigMap) {
|
||||||
|
mak.Set(&cm.Data, "dns.json", string(bs))
|
||||||
|
})
|
||||||
|
expectReconciled(t, nr, "", "test")
|
||||||
|
wantCm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsconfig",
|
||||||
|
Namespace: "tailscale", Labels: labels, OwnerReferences: []metav1.OwnerReference{*dnsCfgOwnerRef}},
|
||||||
|
TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"},
|
||||||
|
Data: map[string]string{"dns.json": string(bs)},
|
||||||
|
}
|
||||||
|
expectEqual(t, fc, wantCm, nil)
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package v1alpha1
|
||||||
|
|
||||||
|
import (
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Code comments on these types should be treated as user facing documentation-
|
||||||
|
// they will appear on the DNSConfig CRD i.e if someone runs kubectl explain dnsconfig.
|
||||||
|
|
||||||
|
var DNSConfigKind = "DNSConfig"
|
||||||
|
|
||||||
|
// +kubebuilder:object:root=true
|
||||||
|
// +kubebuilder:subresource:status
|
||||||
|
// +kubebuilder:resource:scope=Cluster,shortName=dc
|
||||||
|
// +kubebuilder:printcolumn:name="NameserverIP",type="string",JSONPath=`.status.nameserverStatus.ip`,description="Service IP address of the nameserver"
|
||||||
|
|
||||||
|
type DNSConfig struct {
|
||||||
|
metav1.TypeMeta `json:",inline"`
|
||||||
|
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||||
|
|
||||||
|
Spec DNSConfigSpec `json:"spec"`
|
||||||
|
|
||||||
|
// +optional
|
||||||
|
Status DNSConfigStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// +kubebuilder:object:root=true
|
||||||
|
|
||||||
|
type DNSConfigList struct {
|
||||||
|
metav1.TypeMeta `json:",inline"`
|
||||||
|
metav1.ListMeta `json:"metadata"`
|
||||||
|
|
||||||
|
Items []DNSConfig `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSConfigSpec struct {
|
||||||
|
Nameserver *Nameserver `json:"nameserver"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Nameserver struct {
|
||||||
|
// +optional
|
||||||
|
Image *Image `json:"image,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
// +optional
|
||||||
|
Repo string `json:"repo,omitempty"`
|
||||||
|
// +optional
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSConfigStatus struct {
|
||||||
|
// +listType=map
|
||||||
|
// +listMapKey=type
|
||||||
|
// +optional
|
||||||
|
Conditions []ConnectorCondition `json:"conditions"`
|
||||||
|
// +optional
|
||||||
|
NameserverStatus *NameserverStatus `json:"nameserverStatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NameserverStatus struct {
|
||||||
|
// +optional
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const NameserverReady ConnectorConditionType = `NameserverReady`
|
@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package kube
|
||||||
|
|
||||||
|
const Alpha1Version = "v1alpha1"
|
||||||
|
|
||||||
|
type Records struct {
|
||||||
|
// Version is the version of this Records configuration. Version is
|
||||||
|
// intended to be used by ./cmd/k8s-nameserver to determine whether it
|
||||||
|
// can read this records configuration.
|
||||||
|
Version string `json:"version"`
|
||||||
|
// IP4 contains a mapping of DNS names to IPv4 address(es).
|
||||||
|
IP4 map[string][]string `json:"ip4"`
|
||||||
|
}
|
Loading…
Reference in New Issue