diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index 280c03144..948deb33a 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -34,6 +34,7 @@ var Directives = []string{ "hosts", "route53", "federation", + "k8s_external", "kubernetes", "file", "auto", diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go index 397438663..6102d94bd 100644 --- a/core/plugin/zplugin.go +++ b/core/plugin/zplugin.go @@ -20,6 +20,7 @@ import ( _ "github.com/coredns/coredns/plugin/forward" _ "github.com/coredns/coredns/plugin/health" _ "github.com/coredns/coredns/plugin/hosts" + _ "github.com/coredns/coredns/plugin/k8s_external" _ "github.com/coredns/coredns/plugin/kubernetes" _ "github.com/coredns/coredns/plugin/loadbalance" _ "github.com/coredns/coredns/plugin/log" diff --git a/plugin.cfg b/plugin.cfg index 6a8ad0ecd..fe6d7d023 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -43,6 +43,7 @@ template:template hosts:hosts route53:route53 federation:federation +k8s_external:k8s_external kubernetes:kubernetes file:file auto:auto diff --git a/plugin/k8s_external/OWNERS b/plugin/k8s_external/OWNERS new file mode 100644 index 000000000..eee46f686 --- /dev/null +++ b/plugin/k8s_external/OWNERS @@ -0,0 +1,4 @@ +reviewers: + - miekg +approvers: + - miekg diff --git a/plugin/k8s_external/README.md b/plugin/k8s_external/README.md new file mode 100644 index 000000000..3cdf44849 --- /dev/null +++ b/plugin/k8s_external/README.md @@ -0,0 +1,78 @@ +# k8s_external + +## Name + +*k8s_external* - resolve load balancer and external IPs from outside kubernetes clusters. + +## Description + +This plugin allows an additional zone to resolve the external IP address(es) of a Kubernetes +service. This plugin is only useful if the *kubernetes* plugin is also loaded. + +The plugin uses an external zone to resolve in-cluster IP addresses. It only handles queries for A, +AAAA and SRV records, all others result in NODATA responses. To make it a proper DNS zone it handles +SOA and NS queries for the apex of the zone. + +By default the apex of the zone will look like (assuming the zone used is `example.org`): + +~~~ dns +example.org. 5 IN SOA ns1.dns.example.org. hostmaster.example.org. ( + 12345 ; serial + 14400 ; refresh (4 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 5 ; minimum (4 hours) + ) +example.org 5 IN NS ns1.dns.example.org. + +ns1.dns.example.org. 5 IN A .... +ns1.dns.example.org. 5 IN AAAA .... +~~~ + +Note we use the `dns` subdomain to place the records the DNS needs (see the `apex` directive). Also +note the SOA's serial number is static. The IP addresses of the nameserver records are those of the +CoreDNS service. + +The *k8s_external* plugin handles the subdomain `dns` and the apex of the zone by itself, all other +queries are resolved to addresses in the cluster. + +## Syntax + +~~~ +k8s_external [ZONE...] +~~~ + +* **ZONES** zones *k8s_external* should be authoritative for. + +If you want to change the apex domain or use a different TTL for the return records you can use +this extended syntax. + +~~~ +k8s_external [ZONE...] { + apex APEX + ttl TTL +} +~~~ + +* **APEX** is the name (DNS label) to use the apex records, defaults to `dns`. +* `ttl` allows you to set a custom **TTL** for responses. The default is 5 (seconds). + +# Examples + +Enable names under `example.org` to be resolved to in cluster DNS addresses. + +~~~ +. { + kubernetes cluster.local + k8s_external example.org +} +~~~ + +# Also See + +For some background see [resolve external IP address](https://github.com/kubernetes/dns/issues/242). +And [A records for services with Load Balancer IP](https://github.com/coredns/coredns/issues/1851). + +# Bugs + +PTR queries for the reverse zone is not supported. diff --git a/plugin/k8s_external/apex.go b/plugin/k8s_external/apex.go new file mode 100644 index 000000000..f58894817 --- /dev/null +++ b/plugin/k8s_external/apex.go @@ -0,0 +1,110 @@ +package external + +import ( + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// serveApex serves request that hit the zone' apex. A reply is written back to the client. +func (e *External) serveApex(state request.Request) (int, error) { + m := new(dns.Msg) + m.SetReply(state.Req) + switch state.QType() { + case dns.TypeSOA: + m.Answer = []dns.RR{e.soa(state)} + case dns.TypeNS: + m.Answer = []dns.RR{e.ns(state)} + + addr := e.externalAddrFunc(state) + for _, rr := range addr { + rr.Header().Ttl = e.ttl + rr.Header().Name = state.QName() + m.Extra = append(m.Extra, rr) + } + default: + m.Ns = []dns.RR{e.soa(state)} + } + + state.W.WriteMsg(m) + return 0, nil +} + +// serveSubApex serves requests that hit the zones fake 'dns' subdomain where our nameservers live. +func (e *External) serveSubApex(state request.Request) (int, error) { + base, _ := dnsutil.TrimZone(state.Name(), state.Zone) + + m := new(dns.Msg) + m.SetReply(state.Req) + + // base is either dns. of ns1.dns (or another name), if it's longer return nxdomain + switch labels := dns.CountLabel(base); labels { + default: + m.SetRcode(m, dns.RcodeNameError) + m.Ns = []dns.RR{e.soa(state)} + state.W.WriteMsg(m) + return 0, nil + case 2: + nl, _ := dns.NextLabel(base, 0) + ns := base[:nl] + if ns != "ns1." { + // nxdomain + m.SetRcode(m, dns.RcodeNameError) + m.Ns = []dns.RR{e.soa(state)} + state.W.WriteMsg(m) + return 0, nil + } + + addr := e.externalAddrFunc(state) + for _, rr := range addr { + rr.Header().Ttl = e.ttl + rr.Header().Name = state.QName() + switch state.QType() { + case dns.TypeA: + if rr.Header().Rrtype == dns.TypeA { + m.Answer = append(m.Answer, rr) + } + case dns.TypeAAAA: + if rr.Header().Rrtype == dns.TypeAAAA { + m.Answer = append(m.Answer, rr) + } + } + } + + if len(m.Answer) == 0 { + m.Ns = []dns.RR{e.soa(state)} + } + + state.W.WriteMsg(m) + return 0, nil + + case 1: + // nodata for the dns empty non-terminal + m.Ns = []dns.RR{e.soa(state)} + state.W.WriteMsg(m) + return 0, nil + } +} + +func (e *External) soa(state request.Request) *dns.SOA { + header := dns.RR_Header{Name: state.Zone, Rrtype: dns.TypeSOA, Ttl: e.ttl, Class: dns.ClassINET} + + soa := &dns.SOA{Hdr: header, + Mbox: dnsutil.Join(e.hostmaster, e.apex, state.Zone), + Ns: dnsutil.Join("ns1", e.apex, state.Zone), + Serial: 12345, // Also dynamic? + Refresh: 7200, + Retry: 1800, + Expire: 86400, + Minttl: e.ttl, + } + return soa +} + +func (e *External) ns(state request.Request) *dns.NS { + header := dns.RR_Header{Name: state.Zone, Rrtype: dns.TypeNS, Ttl: e.ttl, Class: dns.ClassINET} + ns := &dns.NS{Hdr: header, Ns: dnsutil.Join("ns1", e.apex, state.Zone)} + + return ns +} diff --git a/plugin/k8s_external/apex_test.go b/plugin/k8s_external/apex_test.go new file mode 100644 index 000000000..e6b118d3d --- /dev/null +++ b/plugin/k8s_external/apex_test.go @@ -0,0 +1,105 @@ +package external + +import ( + "context" + "testing" + + "github.com/coredns/coredns/plugin/kubernetes" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestApex(t *testing.T) { + k := kubernetes.New([]string{"cluster.local."}) + k.Namespaces = map[string]struct{}{"testns": struct{}{}} + k.APIConn = &external{} + + e := New() + e.Zones = []string{"example.com."} + e.Next = test.NextHandler(dns.RcodeSuccess, nil) + e.externalFunc = k.External + e.externalAddrFunc = externalAddress // internal test function + + ctx := context.TODO() + for i, tc := range testsApex { + r := tc.Msg() + w := dnstest.NewRecorder(&test.ResponseWriter{}) + + _, err := e.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d expected no error, got %v", i, err) + return + } + if tc.Error != nil { + continue + } + + resp := w.Msg + if resp == nil { + t.Fatalf("Test %d, got nil message and no error for %q", i, r.Question[0].Name) + } + test.SortAndCheck(t, resp, tc) + } +} + +var testsApex = []test.Case{ + { + Qname: "example.com.", Qtype: dns.TypeSOA, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + { + Qname: "example.com.", Qtype: dns.TypeNS, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.NS("example.com. 5 IN NS ns1.dns.example.com."), + }, + Extra: []dns.RR{ + test.A("example.com. 5 IN A 127.0.0.1"), + }, + }, + { + Qname: "example.com.", Qtype: dns.TypeSRV, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + { + Qname: "dns.example.com.", Qtype: dns.TypeSRV, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + { + Qname: "dns.example.com.", Qtype: dns.TypeNS, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + { + Qname: "ns1.dns.example.com.", Qtype: dns.TypeSRV, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + { + Qname: "ns1.dns.example.com.", Qtype: dns.TypeNS, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + { + Qname: "ns1.dns.example.com.", Qtype: dns.TypeAAAA, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + { + Qname: "ns1.dns.example.com.", Qtype: dns.TypeA, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("ns1.dns.example.com. 5 IN A 127.0.0.1"), + }, + }, +} diff --git a/plugin/k8s_external/external.go b/plugin/k8s_external/external.go new file mode 100644 index 000000000..3ca188ed8 --- /dev/null +++ b/plugin/k8s_external/external.go @@ -0,0 +1,112 @@ +/* +Package external implements external names for kubernetes clusters. + +This plugin only handles three qtypes (except the apex queries, because those are handled +differently). We support A, AAAA and SRV request, for all other types we return NODATA or +NXDOMAIN depending on the state of the cluster. + +A plugin willing to provide these services must implement the Externaler interface, although it +likely only makes sense for the *kubernetes* plugin. + +*/ +package external + +import ( + "context" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// Externaler defines the interface that a plugin should implement in order to be used by External. +type Externaler interface { + // External returns a slice of msg.Services that are looked up in the backend and match + // the request. + External(request.Request) ([]msg.Service, int) + // ExternalAddress should return a string slice of addresses for the nameserving endpoint. + ExternalAddress(state request.Request) []dns.RR +} + +// External resolves Ingress and Loadbalance IPs from kubernetes clusters. +type External struct { + Next plugin.Handler + Zones []string + + hostmaster string + apex string + ttl uint32 + + externalFunc func(request.Request) ([]msg.Service, int) + externalAddrFunc func(request.Request) []dns.RR +} + +// New returns a new and initialized *External. +func New() *External { + e := &External{hostmaster: "hostmaster", ttl: 5, apex: "dns"} + return e +} + +// ServeDNS implements the plugin.Handle interface. +func (e *External) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + + zone := plugin.Zones(e.Zones).Matches(state.Name()) + if zone == "" { + return plugin.NextOrFailure(e.Name(), e.Next, ctx, w, r) + } + + if e.externalFunc == nil { + return plugin.NextOrFailure(e.Name(), e.Next, ctx, w, r) + } + + state.Zone = zone + for _, z := range e.Zones { + // TODO(miek): save this in the External struct. + if state.Name() == z { // apex query + ret, err := e.serveApex(state) + return ret, err + } + if dns.IsSubDomain(e.apex+"."+z, state.Name()) { + // dns subdomain test for ns. and dns. queries + ret, err := e.serveSubApex(state) + return ret, err + } + } + + svc, rcode := e.externalFunc(state) + + m := new(dns.Msg) + m.SetReply(state.Req) + + if len(svc) == 0 { + m.Rcode = rcode + m.Ns = []dns.RR{e.soa(state)} + w.WriteMsg(m) + return 0, nil + } + + switch state.QType() { + case dns.TypeA: + m.Answer = e.a(svc, state) + case dns.TypeAAAA: + m.Answer = e.aaaa(svc, state) + case dns.TypeSRV: + m.Answer, m.Extra = e.srv(svc, state) + default: + m.Ns = []dns.RR{e.soa(state)} + } + + // If we did have records, but queried for the wrong qtype return a nodata response. + if len(m.Answer) == 0 { + m.Ns = []dns.RR{e.soa(state)} + } + + w.WriteMsg(m) + return 0, nil +} + +// Name implements the Handler interface. +func (e *External) Name() string { return "k8s_external" } diff --git a/plugin/k8s_external/external_test.go b/plugin/k8s_external/external_test.go new file mode 100644 index 000000000..257a7802b --- /dev/null +++ b/plugin/k8s_external/external_test.go @@ -0,0 +1,210 @@ +package external + +import ( + "context" + "testing" + + "github.com/coredns/coredns/plugin/kubernetes" + "github.com/coredns/coredns/plugin/kubernetes/object" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/pkg/watch" + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + api "k8s.io/api/core/v1" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestExternal(t *testing.T) { + k := kubernetes.New([]string{"cluster.local."}) + k.Namespaces = map[string]struct{}{"testns": struct{}{}} + k.APIConn = &external{} + + e := New() + e.Zones = []string{"example.com."} + e.Next = test.NextHandler(dns.RcodeSuccess, nil) + e.externalFunc = k.External + e.externalAddrFunc = externalAddress // internal test function + + ctx := context.TODO() + for i, tc := range tests { + r := tc.Msg() + w := dnstest.NewRecorder(&test.ResponseWriter{}) + + _, err := e.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d expected no error, got %v", i, err) + return + } + if tc.Error != nil { + continue + } + + resp := w.Msg + if resp == nil { + t.Fatalf("Test %d, got nil message and no error for %q", i, r.Question[0].Name) + } + test.SortAndCheck(t, resp, tc) + } +} + +var tests = []test.Case{ + // A Service + { + Qname: "svc1.testns.example.com.", Qtype: dns.TypeA, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("svc1.testns.example.com. 5 IN A 1.2.3.4"), + }, + }, + { + Qname: "svc1.testns.example.com.", Qtype: dns.TypeSRV, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{test.SRV("svc1.testns.example.com. 5 IN SRV 0 100 80 svc1.testns.example.com.")}, + Extra: []dns.RR{test.A("svc1.testns.example.com. 5 IN A 1.2.3.4")}, + }, + // SRV Service Not udp/tcp + { + Qname: "*._not-udp-or-tcp.svc1.testns.example.com.", Qtype: dns.TypeSRV, Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + // SRV Service + { + Qname: "_http._tcp.svc1.testns.example.com.", Qtype: dns.TypeSRV, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.SRV("_http._tcp.svc1.testns.example.com. 5 IN SRV 0 100 80 svc1.testns.example.com."), + }, + Extra: []dns.RR{ + test.A("svc1.testns.example.com. 5 IN A 1.2.3.4"), + }, + }, + // AAAA Service (with an existing A record, but no AAAA record) + { + Qname: "svc1.testns.example.com.", Qtype: dns.TypeAAAA, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + // AAAA Service (non-existing service) + { + Qname: "svc0.testns.example.com.", Qtype: dns.TypeAAAA, Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + // A Service (non-existing service) + { + Qname: "svc0.testns.example.com.", Qtype: dns.TypeA, Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + // A Service (non-existing namespace) + { + Qname: "svc0.svc-nons.example.com.", Qtype: dns.TypeA, Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + // AAAA Service + { + Qname: "svc6.testns.example.com.", Qtype: dns.TypeAAAA, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.AAAA("svc6.testns.example.com. 5 IN AAAA 1:2::5"), + }, + }, + // SRV + { + Qname: "_http._tcp.svc6.testns.example.com.", Qtype: dns.TypeSRV, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.SRV("_http._tcp.svc6.testns.example.com. 5 IN SRV 0 100 80 svc6.testns.example.com."), + }, + Extra: []dns.RR{ + test.AAAA("svc6.testns.example.com. 5 IN AAAA 1:2::5"), + }, + }, + // SRV + { + Qname: "svc6.testns.example.com.", Qtype: dns.TypeSRV, Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.SRV("svc6.testns.example.com. 5 IN SRV 0 100 80 svc6.testns.example.com."), + }, + Extra: []dns.RR{ + test.AAAA("svc6.testns.example.com. 5 IN AAAA 1:2::5"), + }, + }, + { + Qname: "testns.example.com.", Qtype: dns.TypeA, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, + { + Qname: "testns.example.com.", Qtype: dns.TypeSOA, Rcode: dns.RcodeSuccess, + Ns: []dns.RR{ + test.SOA("example.com. 5 IN SOA ns1.dns.example.com. hostmaster.example.com. 1499347823 7200 1800 86400 5"), + }, + }, +} + +type external struct{} + +func (external) HasSynced() bool { return true } +func (external) Run() { return } +func (external) Stop() error { return nil } +func (external) EpIndexReverse(string) []*object.Endpoints { return nil } +func (external) SvcIndexReverse(string) []*object.Service { return nil } +func (external) Modified() int64 { return 0 } +func (external) SetWatchChan(watch.Chan) {} +func (external) Watch(string) error { return nil } +func (external) StopWatching(string) {} +func (external) EpIndex(s string) []*object.Endpoints { return nil } +func (external) EndpointsList() []*object.Endpoints { return nil } +func (external) GetNodeByName(name string) (*api.Node, error) { return nil, nil } +func (external) SvcIndex(s string) []*object.Service { return svcIndexExternal[s] } +func (external) PodIndex(string) []*object.Pod { return nil } + +func (external) GetNamespaceByName(name string) (*api.Namespace, error) { + return &api.Namespace{ + ObjectMeta: meta.ObjectMeta{ + Name: name, + }, + }, nil +} + +var svcIndexExternal = map[string][]*object.Service{ + "svc1.testns": { + { + Name: "svc1", + Namespace: "testns", + Type: api.ServiceTypeClusterIP, + ClusterIP: "10.0.0.1", + ExternalIPs: []string{"1.2.3.4"}, + Ports: []api.ServicePort{{Name: "http", Protocol: "tcp", Port: 80}}, + }, + }, + "svc6.testns": { + { + Name: "svc6", + Namespace: "testns", + Type: api.ServiceTypeClusterIP, + ClusterIP: "10.0.0.3", + ExternalIPs: []string{"1:2::5"}, + Ports: []api.ServicePort{{Name: "http", Protocol: "tcp", Port: 80}}, + }, + }, +} + +func (external) ServiceList() []*object.Service { + var svcs []*object.Service + for _, svc := range svcIndexExternal { + svcs = append(svcs, svc...) + } + return svcs +} + +func externalAddress(state request.Request) []dns.RR { + a := test.A("example.org. IN A 127.0.0.1") + return []dns.RR{a} +} diff --git a/plugin/k8s_external/msg_to_dns.go b/plugin/k8s_external/msg_to_dns.go new file mode 100644 index 000000000..d09229d48 --- /dev/null +++ b/plugin/k8s_external/msg_to_dns.go @@ -0,0 +1,148 @@ +package external + +import ( + "math" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +func (e *External) a(services []msg.Service, state request.Request) (records []dns.RR) { + dup := make(map[string]struct{}) + + for _, s := range services { + + what, ip := s.HostType() + + switch what { + case dns.TypeCNAME: + // can't happen + + case dns.TypeA: + if _, ok := dup[s.Host]; !ok { + dup[s.Host] = struct{}{} + rr := s.NewA(state.QName(), ip) + rr.Hdr.Ttl = e.ttl + records = append(records, rr) + } + + case dns.TypeAAAA: + // nada + } + } + return records +} + +func (e *External) aaaa(services []msg.Service, state request.Request) (records []dns.RR) { + dup := make(map[string]struct{}) + + for _, s := range services { + + what, ip := s.HostType() + + switch what { + case dns.TypeCNAME: + // can't happen + + case dns.TypeA: + // nada + + case dns.TypeAAAA: + if _, ok := dup[s.Host]; !ok { + dup[s.Host] = struct{}{} + rr := s.NewAAAA(state.QName(), ip) + rr.Hdr.Ttl = e.ttl + records = append(records, rr) + } + } + } + return records +} + +func (e *External) srv(services []msg.Service, state request.Request) (records, extra []dns.RR) { + dup := make(map[item]struct{}) + + // Looping twice to get the right weight vs priority. This might break because we may drop duplicate SRV records latter on. + w := make(map[int]int) + for _, s := range services { + weight := 100 + if s.Weight != 0 { + weight = s.Weight + } + if _, ok := w[s.Priority]; !ok { + w[s.Priority] = weight + continue + } + w[s.Priority] += weight + } + for _, s := range services { + // Don't add the entry if the port is -1 (invalid). The kubernetes plugin uses port -1 when a service/endpoint + // does not have any declared ports. + if s.Port == -1 { + continue + } + w1 := 100.0 / float64(w[s.Priority]) + if s.Weight == 0 { + w1 *= 100 + } else { + w1 *= float64(s.Weight) + } + weight := uint16(math.Floor(w1)) + + what, ip := s.HostType() + + switch what { + case dns.TypeCNAME: + // can't happen + + case dns.TypeA, dns.TypeAAAA: + addr := s.Host + s.Host = msg.Domain(s.Key) + srv := s.NewSRV(state.QName(), weight) + + if ok := isDuplicate(dup, srv.Target, "", srv.Port); !ok { + records = append(records, srv) + } + + if ok := isDuplicate(dup, srv.Target, addr, 0); !ok { + hdr := dns.RR_Header{Name: srv.Target, Rrtype: what, Class: dns.ClassINET, Ttl: e.ttl} + + switch what { + case dns.TypeA: + extra = append(extra, &dns.A{Hdr: hdr, A: ip}) + case dns.TypeAAAA: + extra = append(extra, &dns.AAAA{Hdr: hdr, AAAA: ip}) + } + } + } + } + return records, extra +} + +// not sure if this is even needed. + +// item holds records. +type item struct { + name string // name of the record (either owner or something else unique). + port uint16 // port of the record (used for address records, A and AAAA). + addr string // address of the record (A and AAAA). +} + +// isDuplicate uses m to see if the combo (name, addr, port) already exists. If it does +// not exist already IsDuplicate will also add the record to the map. +func isDuplicate(m map[item]struct{}, name, addr string, port uint16) bool { + if addr != "" { + _, ok := m[item{name, 0, addr}] + if !ok { + m[item{name, 0, addr}] = struct{}{} + } + return ok + } + _, ok := m[item{name, port, ""}] + if !ok { + m[item{name, port, ""}] = struct{}{} + } + return ok +} diff --git a/plugin/k8s_external/setup.go b/plugin/k8s_external/setup.go new file mode 100644 index 000000000..ede34c6ce --- /dev/null +++ b/plugin/k8s_external/setup.go @@ -0,0 +1,86 @@ +package external + +import ( + "strconv" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("k8s_external", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + e, err := parse(c) + if err != nil { + return plugin.Error("k8s_external", err) + } + + // Do this in OnStartup, so all plugins have been initialized. + c.OnStartup(func() error { + m := dnsserver.GetConfig(c).Handler("kubernetes") + if m == nil { + return nil + } + if x, ok := m.(Externaler); ok { + e.externalFunc = x.External + e.externalAddrFunc = x.ExternalAddress + } + return nil + }) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + e.Next = next + return e + }) + + return nil +} + +func parse(c *caddy.Controller) (*External, error) { + e := New() + + for c.Next() { // external + zones := c.RemainingArgs() + e.Zones = zones + if len(zones) == 0 { + e.Zones = make([]string, len(c.ServerBlockKeys)) + copy(e.Zones, c.ServerBlockKeys) + } + for i, str := range e.Zones { + e.Zones[i] = plugin.Host(str).Normalize() + } + for c.NextBlock() { + switch c.Val() { + case "ttl": + args := c.RemainingArgs() + if len(args) == 0 { + return nil, c.ArgErr() + } + t, err := strconv.Atoi(args[0]) + if err != nil { + return nil, err + } + if t < 0 || t > 3600 { + return nil, c.Errf("ttl must be in range [0, 3600]: %d", t) + } + e.ttl = uint32(t) + case "apex": + args := c.RemainingArgs() + if len(args) == 0 { + return nil, c.ArgErr() + } + e.apex = args[0] + default: + return nil, c.Errf("unknown property '%s'", c.Val()) + } + } + } + return e, nil +} diff --git a/plugin/k8s_external/setup_test.go b/plugin/k8s_external/setup_test.go new file mode 100644 index 000000000..4533e13ec --- /dev/null +++ b/plugin/k8s_external/setup_test.go @@ -0,0 +1,48 @@ +package external + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestSetup(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedZone string + expectedApex string + }{ + {`k8s_external`, false, "", "dns"}, + {`k8s_external example.org`, false, "example.org.", "dns"}, + {`k8s_external example.org { + apex testdns +}`, false, "example.org.", "testdns"}, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + e, err := parse(c) + + if test.shouldErr && err == nil { + t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input) + } + + if err != nil { + if !test.shouldErr { + t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err) + } + } + + if !test.shouldErr && test.expectedZone != "" { + if test.expectedZone != e.Zones[0] { + t.Errorf("Test %d, expected zone %q for input %s, got: %q", i, test.expectedZone, test.input, e.Zones[0]) + } + } + if !test.shouldErr { + if test.expectedApex != e.apex { + t.Errorf("Test %d, expected apex %q for input %s, got: %q", i, test.expectedApex, test.input, e.apex) + } + } + } +} diff --git a/plugin/kubernetes/controller.go b/plugin/kubernetes/controller.go index df90fcf82..9a2e9994a 100644 --- a/plugin/kubernetes/controller.go +++ b/plugin/kubernetes/controller.go @@ -172,7 +172,11 @@ func svcIPIndexFunc(obj interface{}) ([]string, error) { if !ok { return nil, errObj } - return []string{svc.ClusterIP}, nil + if len(svc.ExternalIPs) == 0 { + return []string{svc.ClusterIP}, nil + } + + return append([]string{svc.ClusterIP}, svc.ExternalIPs...), nil } func svcNameNamespaceIndexFunc(obj interface{}) ([]string, error) { diff --git a/plugin/kubernetes/external.go b/plugin/kubernetes/external.go new file mode 100644 index 000000000..1770872b3 --- /dev/null +++ b/plugin/kubernetes/external.go @@ -0,0 +1,92 @@ +package kubernetes + +import ( + "strings" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/kubernetes/object" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" +) + +// External implements the ExternalFunc call from the external plugin. +// It returns any services matching in the services' ExternalIPs. +func (k *Kubernetes) External(state request.Request) ([]msg.Service, int) { + base, _ := dnsutil.TrimZone(state.Name(), state.Zone) + + segs := dns.SplitDomainName(base) + last := len(segs) - 1 + if last < 0 { + return nil, dns.RcodeServerFailure + } + // We dealing with a fairly normal domain name here, but; we still need to have the service + // and the namespace: + // service.namespace. + // + // for service (and SRV) you can also say _tcp, and port (i.e. _http), we need those be picked + // up, unless they are not specified, then we use an internal wildcard. + port := "*" + protocol := "*" + namespace := segs[last] + if !k.namespaceExposed(namespace) || !k.namespace(namespace) { + return nil, dns.RcodeNameError + } + + last-- + if last < 0 { + return nil, dns.RcodeSuccess + } + + service := segs[last] + last-- + if last == 1 { + protocol = stripUnderscore(segs[last]) + port = stripUnderscore(segs[last-1]) + last -= 2 + } + + if last != -1 { + // too long + return nil, dns.RcodeNameError + } + + idx := object.ServiceKey(service, namespace) + serviceList := k.APIConn.SvcIndex(idx) + + services := []msg.Service{} + zonePath := msg.Path(state.Zone, coredns) + rcode := dns.RcodeNameError + + for _, svc := range serviceList { + if namespace != svc.Namespace { + continue + } + if service != svc.Name { + continue + } + + for _, ip := range svc.ExternalIPs { + for _, p := range svc.Ports { + if !(match(port, p.Name) && match(protocol, string(p.Protocol))) { + continue + } + rcode = dns.RcodeSuccess + s := msg.Service{Host: ip, Port: int(p.Port), TTL: k.ttl} + s.Key = strings.Join([]string{zonePath, svc.Namespace, svc.Name}, "/") + + services = append(services, s) + } + } + } + return services, rcode +} + +// ExternalAddress returns the external service address(es) for the CoreDNS service. +func (k *Kubernetes) ExternalAddress(state request.Request) []dns.RR { + // This is probably wrong, because of all the fallback behavior of k.nsAddr, i.e. can get + // an address that isn't reacheable from outside the cluster. + rrs := []dns.RR{k.nsAddr()} + return rrs +} diff --git a/plugin/kubernetes/external_test.go b/plugin/kubernetes/external_test.go new file mode 100644 index 000000000..7fb5469e1 --- /dev/null +++ b/plugin/kubernetes/external_test.go @@ -0,0 +1,139 @@ +package kubernetes + +import ( + "testing" + + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/kubernetes/object" + "github.com/coredns/coredns/plugin/pkg/watch" + "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + api "k8s.io/api/core/v1" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var extCases = []struct { + Qname string + Qtype uint16 + Msg []msg.Service + Rcode int +}{ + { + Qname: "svc1.testns.example.org.", Rcode: dns.RcodeSuccess, + Msg: []msg.Service{ + msg.Service{Host: "1.2.3.4", Port: 80, TTL: 5, Key: "/c/org/example/testns/svc1"}, + }, + }, + { + Qname: "svc6.testns.example.org.", Rcode: dns.RcodeSuccess, + Msg: []msg.Service{ + msg.Service{Host: "1:2::5", Port: 80, TTL: 5, Key: "/c/org/example/testns/svc1"}, + }, + }, + { + Qname: "*._not-udp-or-tcp.svc1.testns.example.com.", Rcode: dns.RcodeSuccess, + }, + { + Qname: "_http._tcp.svc1.testns.example.com.", Rcode: dns.RcodeSuccess, + Msg: []msg.Service{ + msg.Service{Host: "1.2.3.4", Port: 80, TTL: 5, Key: "/c/org/example/testns/svc1"}, + }, + }, + { + Qname: "svc0.testns.example.com.", Rcode: dns.RcodeNameError, + }, + { + Qname: "svc0.svc-nons.example.com.", Rcode: dns.RcodeNameError, + }, +} + +func TestExternal(t *testing.T) { + k := New([]string{"cluster.local."}) + k.APIConn = &external{} + k.Next = test.NextHandler(dns.RcodeSuccess, nil) + k.Namespaces = map[string]struct{}{"testns": struct{}{}} + + for i, tc := range extCases { + state := testRequest(tc.Qname) + + svc, rcode := k.External(state) + + if x := tc.Rcode; x != rcode { + t.Errorf("Test %d, expected rcode %d, got %d\n", i, x, rcode) + } + + if len(svc) != len(tc.Msg) { + t.Errorf("Test %d, expected %d for messages, got %d\n", i, len(tc.Msg), len(svc)) + } + + for j, s := range svc { + if x := tc.Msg[j].Key; x != s.Key { + t.Errorf("Test %d, expected key %s, got %s\n", i, x, s.Key) + } + return + } + } +} + +type external struct{} + +func (external) HasSynced() bool { return true } +func (external) Run() { return } +func (external) Stop() error { return nil } +func (external) EpIndexReverse(string) []*object.Endpoints { return nil } +func (external) SvcIndexReverse(string) []*object.Service { return nil } +func (external) Modified() int64 { return 0 } +func (external) SetWatchChan(watch.Chan) {} +func (external) Watch(string) error { return nil } +func (external) StopWatching(string) {} +func (external) EpIndex(s string) []*object.Endpoints { return nil } +func (external) EndpointsList() []*object.Endpoints { return nil } +func (external) GetNodeByName(name string) (*api.Node, error) { return nil, nil } +func (external) SvcIndex(s string) []*object.Service { return svcIndexExternal[s] } +func (external) PodIndex(string) []*object.Pod { return nil } + +func (external) GetNamespaceByName(name string) (*api.Namespace, error) { + return &api.Namespace{ + ObjectMeta: meta.ObjectMeta{ + Name: name, + }, + }, nil +} + +var svcIndexExternal = map[string][]*object.Service{ + "svc1.testns": { + { + Name: "svc1", + Namespace: "testns", + Type: api.ServiceTypeClusterIP, + ClusterIP: "10.0.0.1", + ExternalIPs: []string{"1.2.3.4"}, + Ports: []api.ServicePort{{Name: "http", Protocol: "tcp", Port: 80}}, + }, + }, + "svc6.testns": { + { + Name: "svc6", + Namespace: "testns", + Type: api.ServiceTypeClusterIP, + ClusterIP: "10.0.0.3", + ExternalIPs: []string{"1:2::5"}, + Ports: []api.ServicePort{{Name: "http", Protocol: "tcp", Port: 80}}, + }, + }, +} + +func (external) ServiceList() []*object.Service { + var svcs []*object.Service + for _, svc := range svcIndexExternal { + svcs = append(svcs, svc...) + } + return svcs +} + +func testRequest(name string) request.Request { + m := new(dns.Msg).SetQuestion(name, dns.TypeA) + return request.Request{W: &test.ResponseWriter{}, Req: m, Zone: "example.org."} +} diff --git a/plugin/kubernetes/ns.go b/plugin/kubernetes/ns.go index 2ccb51ef3..f3d33ee22 100644 --- a/plugin/kubernetes/ns.go +++ b/plugin/kubernetes/ns.go @@ -12,6 +12,10 @@ func isDefaultNS(name, zone string) bool { return strings.Index(name, defaultNSName) == 0 && strings.Index(name, zone) == len(defaultNSName) } +// nsAddr return the A record for the CoreDNS service in the cluster. If it fails that it fallsback +// on the local address of the machine we're running on. +// +// This function is rather expensive to run. func (k *Kubernetes) nsAddr() *dns.A { var ( svcName string diff --git a/plugin/kubernetes/object/service.go b/plugin/kubernetes/object/service.go index be010e96b..af9a42b48 100644 --- a/plugin/kubernetes/object/service.go +++ b/plugin/kubernetes/object/service.go @@ -16,6 +16,9 @@ type Service struct { ExternalName string Ports []api.ServicePort + // ExternalIPs we may want to export. + ExternalIPs []string + *Empty } @@ -37,6 +40,8 @@ func ToService(obj interface{}) interface{} { ClusterIP: svc.Spec.ClusterIP, Type: svc.Spec.Type, ExternalName: svc.Spec.ExternalName, + + ExternalIPs: make([]string, len(svc.Status.LoadBalancer.Ingress)+len(svc.Spec.ExternalIPs)), } if len(svc.Spec.Ports) == 0 { @@ -47,6 +52,11 @@ func ToService(obj interface{}) interface{} { copy(s.Ports, svc.Spec.Ports) } + li := copy(s.ExternalIPs, svc.Spec.ExternalIPs) + for i, lb := range svc.Status.LoadBalancer.Ingress { + s.ExternalIPs[li+i] = lb.IP + } + *svc = api.Service{} return s @@ -65,8 +75,10 @@ func (s *Service) DeepCopyObject() runtime.Object { Type: s.Type, ExternalName: s.ExternalName, Ports: make([]api.ServicePort, len(s.Ports)), + ExternalIPs: make([]string, len(s.ExternalIPs)), } copy(s1.Ports, s.Ports) + copy(s1.ExternalIPs, s.ExternalIPs) return s1 }