mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
9e2f58f846
* cmd/k8s-nameserver,k8s-operator: add a nameserver that can resolve ts.net DNS names in cluster. Adds a simple nameserver that can respond to A record queries for ts.net DNS names. It can respond to queries from in-memory records, populated from a ConfigMap mounted at /config. It dynamically updates its records as the ConfigMap contents changes. It will respond with NXDOMAIN to queries for any other record types (AAAA to be implemented in the future). It can respond to queries over UDP or TCP. It runs a miekg/dns DNS server with a single registered handler for ts.net domain names. Queries for other domain names will be refused. The intended use of this is: 1) to allow non-tailnet cluster workloads to talk to HTTPS tailnet services exposed via Tailscale operator egress over HTTPS 2) to allow non-tailnet cluster workloads to talk to workloads in the same cluster that have been exposed to tailnet over their MagicDNS names but on their cluster IPs. Updates tailscale/tailscale#10499 Signed-off-by: Irbe Krumina <irbe@tailscale.com> * cmd/k8s-operator/deploy/crds,k8s-operator: add DNSConfig CustomResource Definition DNSConfig CRD can be used to configure the operator to deploy kube nameserver (./cmd/k8s-nameserver) to cluster. Signed-off-by: Irbe Krumina <irbe@tailscale.com> * cmd/k8s-operator,k8s-operator: optionally reconcile nameserver resources Adds a new reconciler that reconciles DNSConfig resources. If a DNSConfig is deployed to cluster, the reconciler creates kube nameserver resources. This reconciler is only responsible for creating nameserver resources and not for populating nameserver's records. Signed-off-by: Irbe Krumina <irbe@tailscale.com> * cmd/{k8s-operator,k8s-nameserver}: generate DNSConfig CRD for charts, append to static manifests Signed-off-by: Irbe Krumina <irbe@tailscale.com> --------- Signed-off-by: Irbe Krumina <irbe@tailscale.com>
228 lines
6.8 KiB
Go
228 lines
6.8 KiB
Go
// 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() {}
|