mirror of https://github.com/tailscale/tailscale/
Revert "cmd/{k8s-nameserver,k8s-operator},k8s-operator: add a kube nameserver, make operator deploy it (#11017)" (#11669)
Temporarily reverting this PR to avoid releasing
half finished featue.
This reverts commit 9e2f58f846
.
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
pull/11670/head
parent
0001237253
commit
231e44e742
@ -1,348 +0,0 @@
|
|||||||
// 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\n", 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\n")
|
|
||||||
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): what TTL?
|
|
||||||
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
|
|
||||||
fallthrough
|
|
||||||
default:
|
|
||||||
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s\n", 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...\n")
|
|
||||||
if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts
|
|
||||||
log.Fatalf("error setting nameserver's records: %v\n", err)
|
|
||||||
}
|
|
||||||
log.Print("nameserver's records were updated\n")
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
log.Printf("context cancelled, exiting records reconciler\n")
|
|
||||||
return
|
|
||||||
case <-n.configWatcher:
|
|
||||||
log.Print("configuration update detected, resetting records\n")
|
|
||||||
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\n", err)
|
|
||||||
}
|
|
||||||
log.Print("nameserver records were reset\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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\n", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 {
|
|
||||||
log.Print("nameserver's configuration is empty, any in-memory records will be unset\n")
|
|
||||||
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 dnsCfg.IP4 == nil || len(dnsCfg.IP4) == 0 {
|
|
||||||
log.Print("nameserver's configuration contains no records, any in-memory records will be unset\n")
|
|
||||||
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\n", 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\n", 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\n", net)
|
|
||||||
s.Shutdown()
|
|
||||||
}()
|
|
||||||
log.Printf("listening for %s queries on %s\n", net, addr)
|
|
||||||
if err := s.ListenAndServe(); err != nil {
|
|
||||||
log.Fatalf("error running %s server: %v\n", 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\n", 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\n", defaultDNSConfigDir)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
log.Print("context cancelled, exiting ConfigMap watcher\n")
|
|
||||||
return
|
|
||||||
case event, ok := <-watcher.Events:
|
|
||||||
if !ok {
|
|
||||||
log.Fatal("watcher finished; exiting")
|
|
||||||
}
|
|
||||||
if event.Name == toWatch {
|
|
||||||
msg := fmt.Sprintf("ConfigMap update received: %s\n", event)
|
|
||||||
log.Print(msg)
|
|
||||||
c <- msg
|
|
||||||
}
|
|
||||||
case err, ok := <-watcher.Errors:
|
|
||||||
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] configuration watcher error: errors watcher finished: %v\n", err)
|
|
||||||
}
|
|
||||||
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\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if err = watcher.Add(defaultDNSConfigDir); err != nil {
|
|
||||||
log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v\n", 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
|
|
||||||
}
|
|
@ -1,227 +0,0 @@
|
|||||||
// 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() {}
|
|
@ -1,96 +0,0 @@
|
|||||||
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: {}
|
|
@ -1,4 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: dnsconfig
|
|
@ -1,37 +0,0 @@
|
|||||||
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
|
|
@ -1,6 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: nameserver
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: foo
|
|
@ -1,16 +0,0 @@
|
|||||||
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
|
|
@ -1,278 +0,0 @@
|
|||||||
// 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"
|
|
||||||
"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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
@ -1,118 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
// 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`
|
|
@ -1,17 +0,0 @@
|
|||||||
// 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