diff --git a/Makefile b/Makefile index 1f9427ceb..b77f631d6 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,14 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie @test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1) TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh +publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO} + @test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1) + @test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1) + @test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1) + @test "${REPO}" != "tailscale/k8s-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1) + @test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1) + TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh + help: ## Show this help @echo "\nSpecify a command. The choices are:\n" @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}' diff --git a/build_docker.sh b/build_docker.sh index 8c134fbc5..c5b81f2ac 100755 --- a/build_docker.sh +++ b/build_docker.sh @@ -70,6 +70,22 @@ case "$TARGET" in --target="${PLATFORM}" \ /usr/local/bin/operator ;; + k8s-nameserver) + DEFAULT_REPOS="tailscale/k8s-nameserver" + REPOS="${REPOS:-${DEFAULT_REPOS}}" + go run github.com/tailscale/mkctr \ + --gopaths="tailscale.com/cmd/k8s-nameserver:/usr/local/bin/k8s-nameserver" \ + --ldflags=" \ + -X tailscale.com/version.longStamp=${VERSION_LONG} \ + -X tailscale.com/version.shortStamp=${VERSION_SHORT} \ + -X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \ + --base="${BASE}" \ + --tags="${TAGS}" \ + --repos="${REPOS}" \ + --push="${PUSH}" \ + --target="${PLATFORM}" \ + /usr/local/bin/k8s-nameserver + ;; *) echo "unknown target: $TARGET" exit 1 diff --git a/cmd/k8s-nameserver/main.go b/cmd/k8s-nameserver/main.go new file mode 100644 index 000000000..871c192a3 --- /dev/null +++ b/cmd/k8s-nameserver/main.go @@ -0,0 +1,348 @@ +// 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 /..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 +} diff --git a/cmd/k8s-nameserver/main_test.go b/cmd/k8s-nameserver/main_test.go new file mode 100644 index 000000000..2c0367e6e --- /dev/null +++ b/cmd/k8s-nameserver/main_test.go @@ -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() {} diff --git a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml index ff518e40c..0dc61f4f4 100644 --- a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml +++ b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml @@ -24,6 +24,9 @@ rules: - apiGroups: ["tailscale.com"] resources: ["connectors", "connectors/status", "proxyclasses", "proxyclasses/status"] verbs: ["get", "list", "watch", "update"] +- apiGroups: ["tailscale.com"] + resources: ["dnsconfigs", "dnsconfigs/status"] + verbs: ["get", "list", "watch", "update"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -45,10 +48,10 @@ metadata: namespace: {{ .Release.Namespace }} rules: - apiGroups: [""] - resources: ["secrets"] + resources: ["secrets", "serviceaccounts", "configmaps"] verbs: ["*"] - apiGroups: ["apps"] - resources: ["statefulsets"] + resources: ["statefulsets", "deployments"] verbs: ["*"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml new file mode 100644 index 000000000..ba4a66c98 --- /dev/null +++ b/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml @@ -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: {} diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml new file mode 100644 index 000000000..febabb374 --- /dev/null +++ b/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: dnsconfig diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml new file mode 100644 index 000000000..735f59b79 --- /dev/null +++ b/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml @@ -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 diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml new file mode 100644 index 000000000..b5a87b762 --- /dev/null +++ b/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nameserver +imagePullSecrets: +- name: foo diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml new file mode 100644 index 000000000..08a90c176 --- /dev/null +++ b/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml @@ -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 diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml index 62e444f82..c83a1165e 100644 --- a/cmd/k8s-operator/deploy/manifests/operator.yaml +++ b/cmd/k8s-operator/deploy/manifests/operator.yaml @@ -158,6 +158,103 @@ spec: --- 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: + 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: + properties: + nameserver: + properties: + image: + properties: + repo: + type: string + tag: + type: string + type: object + type: object + required: + - nameserver + type: object + status: + properties: + conditions: + items: + description: ConnectorCondition contains condition information for a Connector. + properties: + lastTransitionTime: + description: LastTransitionTime is the timestamp corresponding to the last status change of this condition. + format: date-time + type: string + 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. + format: int64 + type: integer + 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 + required: + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + nameserverStatus: + properties: + ip: + type: string + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.13.0 @@ -691,6 +788,16 @@ rules: - list - watch - update + - apiGroups: + - tailscale.com + resources: + - dnsconfigs + - dnsconfigs/status + verbs: + - get + - list + - watch + - update --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -715,12 +822,15 @@ rules: - "" resources: - secrets + - serviceaccounts + - configmaps verbs: - '*' - apiGroups: - apps resources: - statefulsets + - deployments verbs: - '*' --- diff --git a/cmd/k8s-operator/generate/main.go b/cmd/k8s-operator/generate/main.go index 7f2b18bf9..64fb7d991 100644 --- a/cmd/k8s-operator/generate/main.go +++ b/cmd/k8s-operator/generate/main.go @@ -22,9 +22,11 @@ const ( operatorDeploymentFilesPath = "cmd/k8s-operator/deploy" connectorCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml" proxyClassCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxyclasses.yaml" + dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml" helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates" connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml" proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml" + dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml" helmConditionalStart = "{{ if .Values.installCRDs -}}\n" helmConditionalEnd = "{{- end -}}" @@ -108,7 +110,7 @@ func main() { } } -// generate places tailscale.com CRDs (currently Connector and ProxyClass) into +// generate places tailscale.com CRDs (currently Connector, ProxyClass and DNSConfig) into // the Helm chart templates behind .Values.installCRDs=true condition (true by // default). func generate(baseDir string) error { @@ -140,6 +142,9 @@ func generate(baseDir string) error { if err := addCRDToHelm(proxyClassCRDPath, proxyClassCRDHelmTemplatePath); err != nil { return fmt.Errorf("error adding ProxyClass CRD to Helm templates: %w", err) } + if err := addCRDToHelm(dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath); err != nil { + return fmt.Errorf("error adding DNSConfig CRD to Helm templates: %w", err) + } return nil } @@ -151,5 +156,8 @@ func cleanup(baseDir string) error { if err := os.Remove(filepath.Join(baseDir, proxyClassCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) { return fmt.Errorf("error cleaning up ProxyClass CRD template: %w", err) } + if err := os.Remove(filepath.Join(baseDir, dnsConfigCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error cleaning up DNSConfig CRD template: %w", err) + } return nil } diff --git a/cmd/k8s-operator/generate/main_test.go b/cmd/k8s-operator/generate/main_test.go index 7e309c1fd..febef6789 100644 --- a/cmd/k8s-operator/generate/main_test.go +++ b/cmd/k8s-operator/generate/main_test.go @@ -56,6 +56,9 @@ func Test_generate(t *testing.T) { if !strings.Contains(installContentsWithCRD.String(), "name: proxyclasses.tailscale.com") { t.Errorf("ProxyClass CRD not found in default chart install") } + if !strings.Contains(installContentsWithCRD.String(), "name: dnsconfigs.tailscale.com") { + t.Errorf("DNSConfig CRD not found in default chart install") + } // Test that CRDs can be excluded from Helm chart install installContentsWithoutCRD := bytes.NewBuffer([]byte{}) @@ -71,4 +74,7 @@ func Test_generate(t *testing.T) { if strings.Contains(installContentsWithoutCRD.String(), "name: connectors.tailscale.com") { t.Errorf("ProxyClass CRD found in chart install that should not contain a CRD") } + if strings.Contains(installContentsWithoutCRD.String(), "name: dnsconfigs.tailscale.com") { + t.Errorf("DNSConfig CRD found in chart install that should not contain a CRD") + } } diff --git a/cmd/k8s-operator/nameserver.go b/cmd/k8s-operator/nameserver.go new file mode 100644 index 000000000..292038aa0 --- /dev/null +++ b/cmd/k8s-operator/nameserver.go @@ -0,0 +1,278 @@ +// 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 + }, + } +) diff --git a/cmd/k8s-operator/nameserver_test.go b/cmd/k8s-operator/nameserver_test.go new file mode 100644 index 000000000..a64402c81 --- /dev/null +++ b/cmd/k8s-operator/nameserver_test.go @@ -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) + + // 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) + + // // 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) + + // 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) +} diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go index 6993b20fb..4af00e5c0 100644 --- a/cmd/k8s-operator/operator.go +++ b/cmd/k8s-operator/operator.go @@ -223,8 +223,11 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string // resources that we GET via the controller manager's client. Cache: cache.Options{ ByObject: map[client.Object]cache.ByObject{ - &corev1.Secret{}: nsFilter, - &appsv1.StatefulSet{}: nsFilter, + &corev1.Secret{}: nsFilter, + &corev1.ServiceAccount{}: nsFilter, + &corev1.ConfigMap{}: nsFilter, + &appsv1.StatefulSet{}: nsFilter, + &appsv1.Deployment{}: nsFilter, }, }, Scheme: tsapi.GlobalScheme, @@ -308,7 +311,28 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string clock: tstime.DefaultClock{}, }) if err != nil { - startlog.Fatal("could not create connector reconciler: %v", err) + startlog.Fatalf("could not create connector reconciler: %v", err) + } + // TODO (irbekrm): switch to metadata-only watches for resources whose + // spec we don't need to inspect to reduce memory consumption + // https://github.com/kubernetes-sigs/controller-runtime/issues/1159 + nameserverFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("nameserver")) + err = builder.ControllerManagedBy(mgr). + For(&tsapi.DNSConfig{}). + Watches(&appsv1.Deployment{}, nameserverFilter). + Watches(&corev1.ConfigMap{}, nameserverFilter). + Watches(&corev1.Service{}, nameserverFilter). + Watches(&corev1.ServiceAccount{}, nameserverFilter). + Complete(&NameserverReconciler{ + recorder: eventRecorder, + tsNamespace: tsNamespace, + + Client: mgr.GetClient(), + logger: zlog.Named("nameserver-reconciler"), + clock: tstime.DefaultClock{}, + }) + if err != nil { + startlog.Fatalf("could not create nameserver reconciler: %v", err) } err = builder.ControllerManagedBy(mgr). For(&tsapi.ProxyClass{}). diff --git a/cmd/k8s-operator/operator_test.go b/cmd/k8s-operator/operator_test.go index 7188ce65b..d9ff51bf6 100644 --- a/cmd/k8s-operator/operator_test.go +++ b/cmd/k8s-operator/operator_test.go @@ -1194,7 +1194,6 @@ func TestTailscaledConfigfileHash(t *testing.T) { expectReconciled(t, sr, "default", "test") expectEqual(t, fc, expectedSTS(t, fc, o), nil) } - func Test_isMagicDNSName(t *testing.T) { tests := []struct { in string diff --git a/k8s-operator/api.md b/k8s-operator/api.md index e2cd47c25..21daf5f3b 100644 --- a/k8s-operator/api.md +++ b/k8s-operator/api.md @@ -10,6 +10,8 @@ Resource Types: - [Connector](#connector) +- [DNSConfig](#dnsconfig) + - [ProxyClass](#proxyclass) @@ -259,6 +261,274 @@ ConnectorCondition contains condition information for a Connector. +## DNSConfig +[↩ Parent](#tailscalecomv1alpha1 ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringtailscale.com/v1alpha1true
kindstringDNSConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject +
+
true
statusobject +
+
false
+ + +### DNSConfig.spec +[↩ Parent](#dnsconfig) + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
nameserverobject +
+
true
+ + +### DNSConfig.spec.nameserver +[↩ Parent](#dnsconfigspec) + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
imageobject +
+
false
+ + +### DNSConfig.spec.nameserver.image +[↩ Parent](#dnsconfigspecnameserver) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
repostring +
+
false
tagstring +
+
false
+ + +### DNSConfig.status +[↩ Parent](#dnsconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object +
+
false
nameserverStatusobject +
+
false
+ + +### DNSConfig.status.conditions[index] +[↩ Parent](#dnsconfigstatus) + + + +ConnectorCondition contains condition information for a Connector. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
statusstring + Status of the condition, one of ('True', 'False', 'Unknown').
+
true
typestring + Type of the condition, known values are (`SubnetRouterReady`).
+
true
lastTransitionTimestring + LastTransitionTime is the timestamp corresponding to the last status change of this condition.
+
+ Format: date-time
+
false
messagestring + Message is a human readable description of the details of the last transition, complementing reason.
+
false
observedGenerationinteger + 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.
+
+ Format: int64
+
false
reasonstring + Reason is a brief machine readable explanation for the condition's last transition.
+
false
+ + +### DNSConfig.status.nameserverStatus +[↩ Parent](#dnsconfigstatus) + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
ipstring +
+
false
+ ## ProxyClass [↩ Parent](#tailscalecomv1alpha1 ) diff --git a/k8s-operator/apis/v1alpha1/register.go b/k8s-operator/apis/v1alpha1/register.go index 4e6bfda64..8c888ff05 100644 --- a/k8s-operator/apis/v1alpha1/register.go +++ b/k8s-operator/apis/v1alpha1/register.go @@ -49,7 +49,7 @@ func init() { // Adds the list of known types to api.Scheme. func addKnownTypes(scheme *runtime.Scheme) error { - scheme.AddKnownTypes(SchemeGroupVersion, &Connector{}, &ConnectorList{}, &ProxyClass{}, &ProxyClassList{}) + scheme.AddKnownTypes(SchemeGroupVersion, &Connector{}, &ConnectorList{}, &ProxyClass{}, &ProxyClassList{}, &DNSConfig{}, &DNSConfigList{}) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go b/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go new file mode 100644 index 000000000..7104d3c13 --- /dev/null +++ b/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go @@ -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` diff --git a/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go b/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go index efd202eee..f6cabe184 100644 --- a/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go +++ b/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go @@ -158,6 +158,162 @@ func (in *Container) DeepCopy() *Container { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSConfig) DeepCopyInto(out *DNSConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfig. +func (in *DNSConfig) DeepCopy() *DNSConfig { + if in == nil { + return nil + } + out := new(DNSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSConfigList) DeepCopyInto(out *DNSConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigList. +func (in *DNSConfigList) DeepCopy() *DNSConfigList { + if in == nil { + return nil + } + out := new(DNSConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSConfigSpec) DeepCopyInto(out *DNSConfigSpec) { + *out = *in + if in.Nameserver != nil { + in, out := &in.Nameserver, &out.Nameserver + *out = new(Nameserver) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigSpec. +func (in *DNSConfigSpec) DeepCopy() *DNSConfigSpec { + if in == nil { + return nil + } + out := new(DNSConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSConfigStatus) DeepCopyInto(out *DNSConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]ConnectorCondition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.NameserverStatus != nil { + in, out := &in.NameserverStatus, &out.NameserverStatus + *out = new(NameserverStatus) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigStatus. +func (in *DNSConfigStatus) DeepCopy() *DNSConfigStatus { + if in == nil { + return nil + } + out := new(DNSConfigStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Image) DeepCopyInto(out *Image) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Image. +func (in *Image) DeepCopy() *Image { + if in == nil { + return nil + } + out := new(Image) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Nameserver) DeepCopyInto(out *Nameserver) { + *out = *in + if in.Image != nil { + in, out := &in.Image, &out.Image + *out = new(Image) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Nameserver. +func (in *Nameserver) DeepCopy() *Nameserver { + if in == nil { + return nil + } + out := new(Nameserver) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NameserverStatus) DeepCopyInto(out *NameserverStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NameserverStatus. +func (in *NameserverStatus) DeepCopy() *NameserverStatus { + if in == nil { + return nil + } + out := new(NameserverStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Pod) DeepCopyInto(out *Pod) { *out = *in diff --git a/k8s-operator/conditions.go b/k8s-operator/conditions.go index 146487fb8..cdcdbe770 100644 --- a/k8s-operator/conditions.go +++ b/k8s-operator/conditions.go @@ -24,7 +24,7 @@ func SetConnectorCondition(cn *tsapi.Connector, conditionType tsapi.ConnectorCon cn.Status.Conditions = conds } -// RemoveConnectorCondition will remove condition of the given type. +// RemoveConnectorCondition will remove condition of the given type if it exists. func RemoveConnectorCondition(conn *tsapi.Connector, conditionType tsapi.ConnectorConditionType) { conn.Status.Conditions = slices.DeleteFunc(conn.Status.Conditions, func(cond tsapi.ConnectorCondition) bool { return cond.Type == conditionType @@ -39,6 +39,14 @@ func SetProxyClassCondition(pc *tsapi.ProxyClass, conditionType tsapi.ConnectorC pc.Status.Conditions = conds } +// SetDNSConfigCondition ensures that DNSConfig status has a condition with the +// given attributes. LastTransitionTime gets set every time condition's status +// changes +func SetDNSConfigCondition(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) { + conds := updateCondition(dnsCfg.Status.Conditions, conditionType, status, reason, message, gen, clock, logger) + dnsCfg.Status.Conditions = conds +} + func updateCondition(conds []tsapi.ConnectorCondition, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) []tsapi.ConnectorCondition { newCondition := tsapi.ConnectorCondition{ Type: conditionType, @@ -61,8 +69,9 @@ func updateCondition(conds []tsapi.ConnectorCondition, conditionType tsapi.Conne } cond := conds[idx] // update the existing condition - // If this update doesn't contain a state transition, we don't update - // the conditions LastTransitionTime to Now(). + + // If this update doesn't contain a state transition, don't update last + // transition time. if cond.Status == status { newCondition.LastTransitionTime = cond.LastTransitionTime } else { @@ -82,3 +91,14 @@ func ProxyClassIsReady(pc *tsapi.ProxyClass) bool { cond := pc.Status.Conditions[idx] return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == pc.Generation } + +func DNSCfgIsReady(cfg *tsapi.DNSConfig) bool { + idx := xslices.IndexFunc(cfg.Status.Conditions, func(cond tsapi.ConnectorCondition) bool { + return cond.Type == tsapi.NameserverReady + }) + if idx == -1 { + return false + } + cond := cfg.Status.Conditions[idx] + return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == cfg.Generation +} diff --git a/k8s-operator/conditions_test.go b/k8s-operator/conditions_test.go index 624891d75..43fea9cb7 100644 --- a/k8s-operator/conditions_test.go +++ b/k8s-operator/conditions_test.go @@ -98,5 +98,4 @@ func TestSetConnectorCondition(t *testing.T) { }, }, }) - } diff --git a/k8s-operator/tsdns.go b/k8s-operator/tsdns.go new file mode 100644 index 000000000..79e77a561 --- /dev/null +++ b/k8s-operator/tsdns.go @@ -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"` +}