diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index 9bfcb1f31..a237cbf34 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -64,4 +64,5 @@ var Directives = []string{ "on", "sign", "view", + "nomad", } diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go index 3aa6b10b5..025c04474 100644 --- a/core/plugin/zplugin.go +++ b/core/plugin/zplugin.go @@ -40,6 +40,7 @@ import ( _ "github.com/coredns/coredns/plugin/metrics" _ "github.com/coredns/coredns/plugin/minimal" _ "github.com/coredns/coredns/plugin/multisocket" + _ "github.com/coredns/coredns/plugin/nomad" _ "github.com/coredns/coredns/plugin/nsid" _ "github.com/coredns/coredns/plugin/pprof" _ "github.com/coredns/coredns/plugin/quic" diff --git a/go.mod b/go.mod index 7732c9822..5f03186ed 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/farsightsec/golang-framestream v0.3.0 github.com/go-logr/logr v1.4.3 github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 + github.com/hashicorp/nomad/api v0.0.0-20250909143645-a3b86c697f38 // v1.10.5 github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9 github.com/matttproud/golang_protobuf_extensions v1.0.4 github.com/miekg/dns v1.1.68 @@ -115,7 +116,13 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/hashicorp/cronexpr v1.1.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -123,6 +130,7 @@ require ( github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index b76e416c5..b4d01f96b 100644 --- a/go.sum +++ b/go.sum @@ -127,6 +127,8 @@ github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/dnstap/golang-dnstap v0.4.0 h1:KRHBoURygdGtBjDI2w4HifJfMAhhOqDuktAokaSa234= github.com/dnstap/golang-dnstap v0.4.0/go.mod h1:FqsSdH58NAmkAvKcpyxht7i4FoBjKu8E4JUPt8ipSUs= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -207,12 +209,27 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/hashicorp/cronexpr v1.1.3 h1:rl5IkxXN2m681EfivTlccqIryzYJSXRGRNa0xeG7NA4= +github.com/hashicorp/cronexpr v1.1.3/go.mod h1:P4wA0KBl9C5q2hABiMO7cp6jcIg96CDh1Efb3g1PWA4= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/nomad/api v0.0.0-20250909143645-a3b86c697f38 h1:1LTbcTpGdSdbj0ee7YZHNe4R2XqxfyWwIkSGWRhgkfM= +github.com/hashicorp/nomad/api v0.0.0-20250909143645-a3b86c697f38/go.mod h1:0Tdp+9HbvwrxprXv/LfYZ8P21bOl4oA8Afyet1kUvhI= github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9 h1:w66aaP3c6SIQ0pi3QH1Tb4AMO3aWoEPxd1CNvLphbkA= github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9/go.mod h1:BaIJzjD2ZnHmx2acPF6XfGLPzNCMiBbMRqJr+8/8uRI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -243,6 +260,8 @@ github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -306,6 +325,8 @@ github.com/secure-systems-lab/go-securesystemslib v0.9.0 h1:rf1HIbL64nUpEIZnjLZ3 github.com/secure-systems-lab/go-securesystemslib v0.9.0/go.mod h1:DVHKMcZ+V4/woA/peqr+L0joiRXbPpQ042GgJckkFgw= github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE= github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= +github.com/shoenig/test v1.12.1 h1:mLHfnMv7gmhhP44WrvT+nKSxKkPDiNkIuHGdIGI9RLU= +github.com/shoenig/test v1.12.1/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= diff --git a/man/coredns-nomad.7 b/man/coredns-nomad.7 new file mode 100644 index 000000000..18585d2ed --- /dev/null +++ b/man/coredns-nomad.7 @@ -0,0 +1,292 @@ +.\" Generated by Mmark Markdown Processer - mmark.miek.nl +.TH "COREDNS-NOMAD" 7 "September 2025" "CoreDNS" "CoreDNS Plugins" + +.SH "NAME" +.PP +\fInomad\fP - enables reading zone data from a Nomad cluster. + +.SH "DESCRIPTION" +.PP +This plugin serves DNS records for services registered with Nomad. Nomad 1.3+ comes with support for discovering services +\[la]https://www.hashicorp.com/en/blog/nomad-service-discovery\[ra] with an in-built service catalogue that is available via the HTTP API. This plugin extends the HTTP API and provides a DNS interface for querying the service catalogue. + +.PP +The query can be looked up with the format \fB\fC[service].[namespace].service.nomad\fR. The plugin currently handles A, AAAA and SRV records. Refer to #Usage Example +\[la]#usage-example\[ra] for more details. + +.SH "EXAMPLE JOB TEMPLATE" +.PP +.RS + +.nf +job "dns" { + type = "service" + + group "dns" { + network { + port "dns" { + static = 1053 + } + } + task "dns" { + driver = "docker" + + config { + image = "coredns/coredns:latest" + ports = ["dns"] + args = ["\-conf", "/secrets/coredns/Corefile", "\-dns.port", "1053"] + } + + service { + name = "hostmaster" + provider = "nomad" + port = "dns" + address\_mode = "driver" + } + + identity { + env = true + } + + template { + data = <> DiG 9.18.1\-1ubuntu1.2\-Ubuntu <<>> redis.default.service.nomad @127.0.0.1 \-p 1053 +;; global options: +cmd +;; Got answer: +;; \->>HEADER<<\- opcode: QUERY, status: NOERROR, id: 54986 +;; flags: qr aa rd; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1 +;; WARNING: recursion requested but not available + +;; OPT PSEUDOSECTION: +; EDNS: version: 0, flags:; udp: 1232 +; COOKIE: bdc9237f49a1f744 (echoed) +;; QUESTION SECTION: +;redis.default.service.nomad. IN A + +;; ANSWER SECTION: +redis.default.service.nomad. 10 IN A 192.168.29.76 +redis.default.service.nomad. 10 IN A 192.168.29.76 +redis.default.service.nomad. 10 IN A 192.168.29.76 + +;; Query time: 4 msec +;; SERVER: 127.0.0.1#1053(127.0.0.1) (UDP) +;; WHEN: Thu Jan 05 12:12:25 IST 2023 +;; MSG SIZE rcvd: 165 + +.fi +.RE + +.SS "SRV RECORD" +.PP +Since an A record doesn't contain the port number, SRV record can be used to query the port number of a service. + +.PP +.RS + +.nf +dig redis.default.service.nomad @127.0.0.1 \-p 1053 SRV + +; <<>> DiG 9.18.1\-1ubuntu1.2\-Ubuntu <<>> redis.default.service.nomad @127.0.0.1 \-p 1053 SRV +;; global options: +cmd +;; Got answer: +;; \->>HEADER<<\- opcode: QUERY, status: NOERROR, id: 49945 +;; flags: qr aa rd; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 4 +;; WARNING: recursion requested but not available + +;; OPT PSEUDOSECTION: +; EDNS: version: 0, flags:; udp: 1232 +; COOKIE: 14572535f3ba6648 (echoed) +;; QUESTION SECTION: +;redis.default.service.nomad. IN SRV + +;; ANSWER SECTION: +redis.default.service.nomad. 8 IN SRV 10 10 25395 redis.default.service.nomad. +redis.default.service.nomad. 8 IN SRV 10 10 20888 redis.default.service.nomad. +redis.default.service.nomad. 8 IN SRV 10 10 26292 redis.default.service.nomad. + +;; ADDITIONAL SECTION: +redis.default.service.nomad. 8 IN A 192.168.29.76 +redis.default.service.nomad. 8 IN A 192.168.29.76 +redis.default.service.nomad. 8 IN A 192.168.29.76 + +;; Query time: 0 msec +;; SERVER: 127.0.0.1#1053(127.0.0.1) (UDP) +;; WHEN: Thu Jan 05 12:12:20 IST 2023 +;; MSG SIZE rcvd: 339 + +.fi +.RE + +.SS "SOA RECORD" +.PP +.RS + +.nf +$ dig @localhost \-p 1053 1dns.default.service.nomad. + +; <<>> DiG 9.18.12\-0ubuntu0.22.04.2\-Ubuntu <<>> @localhost \-p 1053 1dns.default.service.nomad. +; (1 server found) +;; global options: +cmd +;; Got answer: +;; \->>HEADER<<\- opcode: QUERY, status: NXDOMAIN, id: 21012 +;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 +;; WARNING: recursion requested but not available + +;; OPT PSEUDOSECTION: +; EDNS: version: 0, flags:; udp: 1232 +; COOKIE: 6d146bb140b4d8ca (echoed) +;; QUESTION SECTION: +;1dns.default.service.nomad. IN A + +;; ANSWER SECTION: +1dns.default.service.nomad. 5 IN SOA ns1.1dns.default.service.nomad. ns1.1dns.default.service.nomad. 1 3600 600 604800 3600 + +;; Query time: 0 msec +;; SERVER: 127.0.0.1#1053(localhost) (UDP) +;; WHEN: Wed Aug 23 21:14:41 EEST 2023 +;; MSG SIZE rcvd: 189 + +.fi +.RE + diff --git a/plugin.cfg b/plugin.cfg index 6b3d716df..b4d3bae03 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -73,3 +73,4 @@ whoami:whoami on:github.com/coredns/caddy/onevent sign:sign view:view +nomad:nomad diff --git a/plugin/nomad/README.md b/plugin/nomad/README.md new file mode 100644 index 000000000..9c3dd20f6 --- /dev/null +++ b/plugin/nomad/README.md @@ -0,0 +1,235 @@ +# nomad + +## Name + +*nomad* - enables reading zone data from a Nomad cluster. + +## Description + +This plugin serves DNS records for services registered with Nomad. Nomad 1.3+ comes with [support for discovering services](https://www.hashicorp.com/en/blog/nomad-service-discovery) with an in-built service catalogue that is available via the HTTP API. This plugin extends the HTTP API and provides a DNS interface for querying the service catalogue. + +The query can be looked up with the format `[service].[namespace].service.nomad`. The plugin currently handles A, AAAA and SRV records. Refer to [#Usage Example](#usage-example) for more details. + +## Example job template + +``` +job "dns" { + type = "service" + + group "dns" { + network { + port "dns" { + static = 1053 + } + } + task "dns" { + driver = "docker" + + config { + image = "coredns/coredns:latest" + ports = ["dns"] + args = ["-conf", "/secrets/coredns/Corefile", "-dns.port", "1053"] + } + + service { + name = "hostmaster" + provider = "nomad" + port = "dns" + address_mode = "driver" + } + + identity { + env = true + } + + template { + data = <> DiG 9.18.1-1ubuntu1.2-Ubuntu <<>> redis.default.service.nomad @127.0.0.1 -p 1053 +;; global options: +cmd +;; Got answer: +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 54986 +;; flags: qr aa rd; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1 +;; WARNING: recursion requested but not available + +;; OPT PSEUDOSECTION: +; EDNS: version: 0, flags:; udp: 1232 +; COOKIE: bdc9237f49a1f744 (echoed) +;; QUESTION SECTION: +;redis.default.service.nomad. IN A + +;; ANSWER SECTION: +redis.default.service.nomad. 10 IN A 192.168.29.76 +redis.default.service.nomad. 10 IN A 192.168.29.76 +redis.default.service.nomad. 10 IN A 192.168.29.76 + +;; Query time: 4 msec +;; SERVER: 127.0.0.1#1053(127.0.0.1) (UDP) +;; WHEN: Thu Jan 05 12:12:25 IST 2023 +;; MSG SIZE rcvd: 165 +``` + +### SRV Record + +Since an A record doesn't contain the port number, SRV record can be used to query the port number of a service. + +``` +dig redis.default.service.nomad @127.0.0.1 -p 1053 SRV + +; <<>> DiG 9.18.1-1ubuntu1.2-Ubuntu <<>> redis.default.service.nomad @127.0.0.1 -p 1053 SRV +;; global options: +cmd +;; Got answer: +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 49945 +;; flags: qr aa rd; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 4 +;; WARNING: recursion requested but not available + +;; OPT PSEUDOSECTION: +; EDNS: version: 0, flags:; udp: 1232 +; COOKIE: 14572535f3ba6648 (echoed) +;; QUESTION SECTION: +;redis.default.service.nomad. IN SRV + +;; ANSWER SECTION: +redis.default.service.nomad. 8 IN SRV 10 10 25395 redis.default.service.nomad. +redis.default.service.nomad. 8 IN SRV 10 10 20888 redis.default.service.nomad. +redis.default.service.nomad. 8 IN SRV 10 10 26292 redis.default.service.nomad. + +;; ADDITIONAL SECTION: +redis.default.service.nomad. 8 IN A 192.168.29.76 +redis.default.service.nomad. 8 IN A 192.168.29.76 +redis.default.service.nomad. 8 IN A 192.168.29.76 + +;; Query time: 0 msec +;; SERVER: 127.0.0.1#1053(127.0.0.1) (UDP) +;; WHEN: Thu Jan 05 12:12:20 IST 2023 +;; MSG SIZE rcvd: 339 +``` + +### SOA Record + +``` +$ dig @localhost -p 1053 1dns.default.service.nomad. + +; <<>> DiG 9.18.12-0ubuntu0.22.04.2-Ubuntu <<>> @localhost -p 1053 1dns.default.service.nomad. +; (1 server found) +;; global options: +cmd +;; Got answer: +;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 21012 +;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 +;; WARNING: recursion requested but not available + +;; OPT PSEUDOSECTION: +; EDNS: version: 0, flags:; udp: 1232 +; COOKIE: 6d146bb140b4d8ca (echoed) +;; QUESTION SECTION: +;1dns.default.service.nomad. IN A + +;; ANSWER SECTION: +1dns.default.service.nomad. 5 IN SOA ns1.1dns.default.service.nomad. ns1.1dns.default.service.nomad. 1 3600 600 604800 3600 + +;; Query time: 0 msec +;; SERVER: 127.0.0.1#1053(localhost) (UDP) +;; WHEN: Wed Aug 23 21:14:41 EEST 2023 +;; MSG SIZE rcvd: 189 +``` diff --git a/plugin/nomad/helpers.go b/plugin/nomad/helpers.go new file mode 100644 index 000000000..4f29d1533 --- /dev/null +++ b/plugin/nomad/helpers.go @@ -0,0 +1,68 @@ +package nomad + +import ( + "net" + + "github.com/hashicorp/nomad/api" + "github.com/miekg/dns" +) + +func addSRVRecord(m *dns.Msg, s *api.ServiceRegistration, header dns.RR_Header, originalQName string, addr net.IP, ttl uint32) error { + srvRecord := &dns.SRV{ + Hdr: header, + Target: originalQName, + Port: uint16(s.Port), + Priority: 10, + Weight: 10, + } + m.Answer = append(m.Answer, srvRecord) + + if addr.To4() == nil { + addExtrasToAAAARecord(m, originalQName, ttl, addr) + } else { + addExtrasToARecord(m, originalQName, ttl, addr) + } + + return nil +} + +func addExtrasToARecord(m *dns.Msg, originalQName string, ttl uint32, addr net.IP) { + header := dns.RR_Header{ + Name: originalQName, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: ttl, + } + m.Extra = append(m.Extra, &dns.A{Hdr: header, A: addr}) +} + +func addExtrasToAAAARecord(m *dns.Msg, originalQName string, ttl uint32, addr net.IP) { + header := dns.RR_Header{ + Name: originalQName, + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: ttl, + } + m.Extra = append(m.Extra, &dns.AAAA{Hdr: header, AAAA: addr}) +} + +func addARecord(m *dns.Msg, header dns.RR_Header, addr net.IP) { + m.Answer = append(m.Answer, &dns.A{Hdr: header, A: addr}) +} + +func addAAAARecord(m *dns.Msg, header dns.RR_Header, addr net.IP) { + m.Answer = append(m.Answer, &dns.AAAA{Hdr: header, AAAA: addr}) +} + +func createSOARecord(originalQName string, ttl uint32, zone string) *dns.SOA { + return &dns.SOA{ + Hdr: dns.RR_Header{Name: dns.Fqdn(originalQName), Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, + Ns: dns.Fqdn("ns1." + originalQName), + Mbox: dns.Fqdn("hostmaster." + zone), + Serial: 0, + Refresh: 3600, + Retry: 600, + Expire: 86400, + Minttl: 30, + } +} diff --git a/plugin/nomad/metrics.go b/plugin/nomad/metrics.go new file mode 100644 index 000000000..aa45f47e9 --- /dev/null +++ b/plugin/nomad/metrics.go @@ -0,0 +1,25 @@ +package nomad + +import ( + "github.com/coredns/coredns/plugin" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // requestSuccessCount is the number of DNS requests handled successfully. + requestSuccessCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: pluginName, + Name: "success_requests_total", + Help: "Counter of DNS requests handled successfully.", + }, []string{"server", "namespace"}) + // requestFailedCount is the number of DNS requests that failed. + requestFailedCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: plugin.Namespace, + Subsystem: pluginName, + Name: "failed_requests_total", + Help: "Counter of DNS requests failed.", + }, []string{"server", "namespace"}) +) diff --git a/plugin/nomad/nomad.go b/plugin/nomad/nomad.go new file mode 100644 index 000000000..c39c445ee --- /dev/null +++ b/plugin/nomad/nomad.go @@ -0,0 +1,158 @@ +package nomad + +import ( + "context" + "fmt" + "net" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/metrics" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + clog "github.com/coredns/coredns/plugin/pkg/log" + "github.com/coredns/coredns/request" + + "github.com/hashicorp/nomad/api" + "github.com/miekg/dns" +) + +const pluginName = "nomad" + +var ( + log = clog.NewWithPlugin(pluginName) + defaultTTL = 30 +) + +type Nomad struct { + Next plugin.Handler + + ttl uint32 + Zone string + clients []*api.Client + current int +} + +func (n *Nomad) Name() string { + return pluginName +} + +func (n Nomad) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + qname, originalQName, err := processQName(state.Name(), n.Zone) + if err != nil { + return plugin.NextOrFailure(n.Name(), n.Next, ctx, w, r) + } + + namespace, serviceName, err := extractNamespaceAndService(qname) + if err != nil { + return plugin.NextOrFailure(n.Name(), n.Next, ctx, w, r) + } + + m, header := initializeMessage(state, n.ttl) + + svcRegistrations, _, err := fetchServiceRegistrations(n, serviceName, namespace) + if err != nil { + log.Warning(err) + return handleServiceLookupError(w, m, ctx, namespace) + } + + if len(svcRegistrations) == 0 { + return handleResponseError(n, w, m, originalQName, n.ttl, ctx, namespace, err) + } + + if err := addServiceResponses(m, svcRegistrations, header, state.QType(), originalQName, n.ttl); err != nil { + return handleResponseError(n, w, m, originalQName, n.ttl, ctx, namespace, err) + } + + err = w.WriteMsg(m) + requestSuccessCount.WithLabelValues(metrics.WithServer(ctx), namespace).Inc() + return dns.RcodeSuccess, err +} + +func processQName(qname, zone string) (string, string, error) { + original := dns.Fqdn(qname) + base, err := dnsutil.TrimZone(original, dns.Fqdn(zone)) + return base, original, err +} + +func extractNamespaceAndService(qname string) (string, string, error) { + qnameSplit := dns.SplitDomainName(qname) + if len(qnameSplit) < 2 { + return "", "", fmt.Errorf("invalid query name") + } + return qnameSplit[1], qnameSplit[0], nil +} + +func initializeMessage(state request.Request, ttl uint32) (*dns.Msg, dns.RR_Header) { + m := new(dns.Msg) + m.SetReply(state.Req) + m.Authoritative, m.Compress, m.Rcode = true, true, dns.RcodeSuccess + + header := dns.RR_Header{ + Name: state.QName(), + Rrtype: state.QType(), + Class: dns.ClassINET, + Ttl: ttl, + } + + return m, header +} + +func fetchServiceRegistrations(n Nomad, serviceName, namespace string) ([]*api.ServiceRegistration, *api.QueryMeta, error) { + log.Debugf("Looking up record for svc: %s namespace: %s", serviceName, namespace) + nc, err := n.getClient() + if err != nil { + return nil, nil, err + } + return nc.Services().Get(serviceName, (&api.QueryOptions{Namespace: namespace})) +} + +func handleServiceLookupError(w dns.ResponseWriter, m *dns.Msg, ctx context.Context, namespace string) (int, error) { + m.Rcode = dns.RcodeSuccess + err := w.WriteMsg(m) + requestFailedCount.WithLabelValues(metrics.WithServer(ctx), namespace).Inc() + return dns.RcodeServerFailure, err +} + +func addServiceResponses(m *dns.Msg, svcRegistrations []*api.ServiceRegistration, header dns.RR_Header, qtype uint16, originalQName string, ttl uint32) error { + for _, s := range svcRegistrations { + addr := net.ParseIP(s.Address) + if addr == nil { + return fmt.Errorf("error parsing IP address") + } + + switch qtype { + case dns.TypeA: + if addr.To4() == nil { + continue + } + addARecord(m, header, addr) + case dns.TypeAAAA: + if addr.To4() != nil { + continue + } + addAAAARecord(m, header, addr) + case dns.TypeSRV: + err := addSRVRecord(m, s, header, originalQName, addr, ttl) + if err != nil { + return err + } + default: + m.Rcode = dns.RcodeNotImplemented + return fmt.Errorf("query type not implemented") + } + } + return nil +} + +func handleResponseError(n Nomad, w dns.ResponseWriter, m *dns.Msg, originalQName string, ttl uint32, ctx context.Context, namespace string, err error) (int, error) { + m.Rcode = dns.RcodeNameError + m.Answer = append(m.Answer, createSOARecord(originalQName, ttl, n.Zone)) + + if writeErr := w.WriteMsg(m); writeErr != nil { + return dns.RcodeServerFailure, fmt.Errorf("write message error: %w", writeErr) + } + + requestFailedCount.WithLabelValues(metrics.WithServer(ctx), namespace).Inc() + + return dns.RcodeSuccess, err +} diff --git a/plugin/nomad/nomad_test.go b/plugin/nomad/nomad_test.go new file mode 100644 index 000000000..da673506c --- /dev/null +++ b/plugin/nomad/nomad_test.go @@ -0,0 +1,204 @@ +package nomad + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + nomad "github.com/hashicorp/nomad/api" + "github.com/miekg/dns" +) + +func TestNomad(t *testing.T) { + var demoNomad = Nomad{ + Next: test.ErrorHandler(), + ttl: uint32(defaultTTL), + Zone: "service.nomad", + clients: make([]*nomad.Client, 0), + } + + var cases = []test.Case{ + { + Qname: "example.default.service.nomad.", + Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("example.default.service.nomad. 30 IN A 1.2.3.4"), + }, + }, + { + Qname: "example.default.service.nomad.", + Qtype: dns.TypeAAAA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{}, + }, + { + Qname: "fakeipv6.default.service.nomad.", + Qtype: dns.TypeAAAA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.AAAA("fakeipv6.default.service.nomad. 30 IN AAAA 1:2:3::4"), + }, + }, + { + Qname: "fakeipv6.default.service.nomad.", + Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{}, + }, + { + Qname: "multi.default.service.nomad.", + Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("multi.default.service.nomad. 30 IN A 1.2.3.4"), + test.A("multi.default.service.nomad. 30 IN A 1.2.3.5"), + test.A("multi.default.service.nomad. 30 IN A 1.2.3.6"), + }, + }, + { + Qname: "nonexistent.default.service.nomad.", + Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Answer: []dns.RR{ + test.SOA("nonexistent.default.service.nomad. 30 IN SOA ns1.nonexistent.default.service.nomad. hostmaster.service.nomad. 0 3600 600 86400 30"), + }, + }, + { + Qname: "example.default.service.nomad.", + Qtype: dns.TypeSRV, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{test.SRV("example.default.service.nomad. 30 IN SRV 10 10 23202 example.default.service.nomad.")}, + Extra: []dns.RR{test.A("example.default.service.nomad. 30 IN A 1.2.3.4")}, + }, + } + + ctx := context.Background() + + // Setup a fake Nomad servers. + nomadServer1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/service/example": + w.Write([]byte(`[{"Address":"1.2.3.4","Namespace":"default","Port":23202,"ServiceName":"example"}]`)) + case "/v1/service/fakeipv6": + w.Write([]byte(`[{"Address":"1:2:3::4","Namespace":"default","Port":8000,"ServiceName":"fakeipv6"}]`)) + case "/v1/service/multi": + w.Write([]byte(`[{"Address":"1.2.3.4","Namespace":"default","Port":25395,"ServiceName":"multi"},{"Address":"1.2.3.5","Namespace":"default","Port":20888,"ServiceName":"multi"},{"Address":"1.2.3.6","Namespace":"default","Port":26292,"ServiceName":"multi"}]`)) + case "/v1/service/nonexistent": + w.Write([]byte(`[]`)) + case "/v1/agent/self": + w.Write([]byte(`{"Member":{"Name":"foobar1"}}`)) + } + })) + defer nomadServer1.Close() + nomadServer2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/service/example": + w.Write([]byte(`[{"Address":"1.2.3.4","Namespace":"default","Port":23202,"ServiceName":"example"}]`)) + case "/v1/service/fakeipv6": + w.Write([]byte(`[{"Address":"1:2:3::4","Namespace":"default","Port":8000,"ServiceName":"fakeipv6"}]`)) + case "/v1/service/multi": + w.Write([]byte(`[{"Address":"1.2.3.4","Namespace":"default","Port":25395,"ServiceName":"multi"},{"Address":"1.2.3.5","Namespace":"default","Port":20888,"ServiceName":"multi"},{"Address":"1.2.3.6","Namespace":"default","Port":26292,"ServiceName":"multi"}]`)) + case "/v1/service/nonexistent": + w.Write([]byte(`[]`)) + case "/v1/agent/self": + w.Write([]byte(`{"Member":{"Name":"foobar2"}}`)) + } + })) + defer nomadServer2.Close() + nomadServer3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/service/example": + w.Write([]byte(`[{"Address":"1.2.3.4","Namespace":"default","Port":23202,"ServiceName":"example"}]`)) + case "/v1/service/fakeipv6": + w.Write([]byte(`[{"Address":"1:2:3::4","Namespace":"default","Port":8000,"ServiceName":"fakeipv6"}]`)) + case "/v1/service/multi": + w.Write([]byte(`[{"Address":"1.2.3.4","Namespace":"default","Port":25395,"ServiceName":"multi"},{"Address":"1.2.3.5","Namespace":"default","Port":20888,"ServiceName":"multi"},{"Address":"1.2.3.6","Namespace":"default","Port":26292,"ServiceName":"multi"}]`)) + case "/v1/service/nonexistent": + w.Write([]byte(`[]`)) + case "/v1/agent/self": + w.Write([]byte(`{"Member":{"Name":"foobar3"}}`)) + } + })) + defer nomadServer3.Close() + + // Configure the plugin to use the fake Nomad server. + cfg := nomad.DefaultConfig() + cfg.Address = nomadServer1.URL + client1, err := nomad.NewClient(cfg) + if err != nil { + t.Errorf("Failed to create Nomad client: %v", err) + return + } + + demoNomad.clients = append(demoNomad.clients, client1) + + cfg = nomad.DefaultConfig() + cfg.Address = nomadServer2.URL + client2, err := nomad.NewClient(cfg) + if err != nil { + t.Errorf("Failed to create Nomad client: %v", err) + return + } + + demoNomad.clients = append(demoNomad.clients, client2) + + cfg = nomad.DefaultConfig() + cfg.Address = nomadServer3.URL + client3, err := nomad.NewClient(cfg) + if err != nil { + t.Errorf("Failed to create Nomad client: %v", err) + return + } + demoNomad.clients = append(demoNomad.clients, client3) + + client, _ := demoNomad.getClient() + if client != client1 { + t.Errorf("Expected client1") + } + + nomadServer1.Close() + + client, _ = demoNomad.getClient() + if client == client1 { + t.Errorf("Expected client2") + } + + client, err = demoNomad.getClient() + if err != nil { + t.Errorf("Expected no error, but got %v", err) + } + if client == nil { + t.Errorf("Expected client2 but got nil") + } + if client != client2 { + t.Errorf("Expected client2") + } + + runTests(ctx, t, &demoNomad, cases) +} + +func runTests(ctx context.Context, t *testing.T, n *Nomad, cases []test.Case) { + t.Helper() + for i, tc := range cases { + r := tc.Msg() + w := dnstest.NewRecorder(&test.ResponseWriter{}) + + _, err := n.ServeDNS(ctx, w, r) + if err != tc.Error { + t.Errorf("Test %d: %v (%v) (%v)", i, err, w, r) + return + } + + if w.Msg == nil { + t.Errorf("Test %d: nil message", i) + } + if err := test.SortAndCheck(w.Msg, tc); err != nil { + t.Errorf("Test %d: %v", i, err) + } + } +} diff --git a/plugin/nomad/ready.go b/plugin/nomad/ready.go new file mode 100644 index 000000000..7a682cf9c --- /dev/null +++ b/plugin/nomad/ready.go @@ -0,0 +1,9 @@ +package nomad + +// Ready signals when the plugin is ready for use. +// In case of Nomad, when the ping to the Nomad API is successful +// the plugin is ready. +func (n Nomad) Ready() bool { + client, _ := n.getClient() + return client != nil +} diff --git a/plugin/nomad/setup.go b/plugin/nomad/setup.go new file mode 100644 index 000000000..94e2178ab --- /dev/null +++ b/plugin/nomad/setup.go @@ -0,0 +1,141 @@ +package nomad + +import ( + "fmt" + "strconv" + "strings" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + nomad "github.com/hashicorp/nomad/api" +) + +// init registers this plugin. +func init() { plugin.Register(pluginName, setup) } + +// setup is the function that gets called when the config parser sees the token "nomad". Setup is responsible +// for parsing any extra options the nomad plugin may have. The first token this function sees is "nomad". +func setup(c *caddy.Controller) error { + n := &Nomad{ + ttl: uint32(defaultTTL), + clients: make([]*nomad.Client, 0), + current: -1, + } + + // Parse the configuration, including the zone argument + if err := parse(c, n); err != nil { + return plugin.Error("nomad", err) + } + + c.OnStartup(func() error { + var err error + for idx, client := range n.clients { + _, err := client.Agent().Self() + if err == nil { + n.current = idx + return nil + } + } + return err + }) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + n.Next = next + return n + }) + + return nil +} + +func parse(c *caddy.Controller, n *Nomad) error { + var token string + addresses := []string{} // Multiple addresses are stored here + + // Expect the first token to be "nomad" + if !c.Next() { + return c.Err("expected 'nomad' token") + } + + // Check for the zone argument + args := c.RemainingArgs() + if len(args) == 0 { + n.Zone = "service.nomad" + } else { + n.Zone = args[0] + } + + // Parse the configuration block + for c.NextBlock() { + selector := strings.ToLower(c.Val()) + + switch selector { + case "address": + args := c.RemainingArgs() + if len(args) == 0 { + return c.Err("at least one address is required") + } + addresses = append(addresses, args...) + case "token": + args := c.RemainingArgs() + if len(args) != 1 { + return c.Err("exactly one token is required") + } + token = args[0] + case "ttl": + args := c.RemainingArgs() + if len(args) != 1 { + return c.Err("exactly one ttl value is required") + } + t, err := strconv.Atoi(args[0]) + if err != nil { + return c.Err("error parsing ttl: " + err.Error()) + } + if t < 0 || t > 3600 { + return c.Errf("ttl must be in range [0, 3600]: %d", t) + } + n.ttl = uint32(t) + default: + return c.Errf("unknown property '%s'", selector) + } + } + + // Push an empty address to create a client solely based on the defaults. + if len(addresses) == 0 { + addresses = append(addresses, "") + } + + for _, addr := range addresses { + cfg := nomad.DefaultConfig() + if len(addr) > 0 { + cfg.Address = addr + } + if len(token) > 0 { + cfg.SecretID = token + } + client, err := nomad.NewClient(cfg) + if err != nil { + return plugin.Error("nomad", err) + } + n.clients = append(n.clients, client) // Store all clients + } + + return nil +} + +func (n *Nomad) getClient() (*nomad.Client, error) { + // Don't bother querying Agent().Self() if there is only one client. + if len(n.clients) == 1 { + return n.clients[0], nil + } + for i := range len(n.clients) { + idx := (n.current + i) % len(n.clients) + _, err := n.clients[idx].Agent().Self() + if err == nil { + n.current = idx + return n.clients[idx], nil + } + } + return nil, fmt.Errorf("no Nomad client available") +} diff --git a/plugin/nomad/setup_test.go b/plugin/nomad/setup_test.go new file mode 100644 index 000000000..880997b3b --- /dev/null +++ b/plugin/nomad/setup_test.go @@ -0,0 +1,110 @@ +package nomad + +import ( + "testing" + + "github.com/coredns/caddy" + + nomad "github.com/hashicorp/nomad/api" +) + +func TestSetupNomad(t *testing.T) { + tests := []struct { + name string + config string + shouldErr bool + expectedTTL uint32 + }{ + { + name: "valid_config_default_ttl", + config: ` +nomad service.nomad { + address http://127.0.0.1:4646 + token test-token +}`, + shouldErr: false, + expectedTTL: uint32(defaultTTL), + }, + { + name: "valid_config_custom_ttl", + config: ` +nomad service.nomad { + address http://127.0.0.1:4646 + token test-token + ttl 60 +}`, + shouldErr: false, + expectedTTL: 60, + }, + { + name: "invalid_ttl_negative", + config: ` +nomad service.nomad { + address http://127.0.0.1:4646 + token test-token + ttl -1 +}`, + shouldErr: true, + }, + { + name: "invalid_ttl_too_large", + config: ` +nomad service.nomad { + address http://127.0.0.1:4646 + token test-token + ttl 3601 +}`, + shouldErr: true, + }, + { + name: "invalid_property", + config: ` +nomad service.nomad { + address http://127.0.0.1:4646 + token test-token + invalid_property +}`, + shouldErr: true, + }, + { + name: "multiple_addresses", + config: ` +nomad service.nomad { + address http://127.0.0.1:4646 http://127.0.0.2:4646 + token test-token +}`, + shouldErr: false, + expectedTTL: uint32(defaultTTL), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := caddy.NewTestController("dns", tt.config) + n := &Nomad{ + ttl: uint32(defaultTTL), + clients: make([]*nomad.Client, 0), + current: -1, + } + + err := parse(c, n) + if tt.shouldErr && err == nil { + t.Fatalf("Test %s: expected error but got none", tt.name) + } + if !tt.shouldErr && err != nil { + t.Fatalf("Test %s: expected no error but got: %v", tt.name, err) + } + if tt.shouldErr { + return + } + + if n.ttl != tt.expectedTTL { + t.Errorf("Test %s: expected TTL %d, got %d", tt.name, tt.expectedTTL, n.ttl) + } + + if len(n.clients) == 0 { + t.Errorf("Test %s: expected at least one client to be created", tt.name) + } + }) + } +}