Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Query node_meta info using DNS TXT query API #3343

Closed
wants to merge 13 commits into from
70 changes: 56 additions & 14 deletions agent/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,8 @@ func (d *DNSServer) nameservers(edns bool) (ns []dns.RR, extra []dns.RR) {
}
ns = append(ns, nsrr)

glue := d.formatNodeRecord(addr, fqdn, dns.TypeANY, d.config.NodeTTL, edns)
// A or AAAA glue record
glue := d.formatNodeRecord(nil, addr, fqdn, dns.TypeANY, d.config.NodeTTL, edns)
extra = append(extra, glue...)

// don't provide more than 3 servers
Expand Down Expand Up @@ -485,9 +486,9 @@ INVALID:

// nodeLookup is used to handle a node query
func (d *DNSServer) nodeLookup(network, datacenter, node string, req, resp *dns.Msg) {
// Only handle ANY, A and AAAA type requests
// Only handle ANY, A, AAAA, and TXT type requests
qType := req.Question[0].Qtype
if qType != dns.TypeANY && qType != dns.TypeA && qType != dns.TypeAAAA {
if qType != dns.TypeANY && qType != dns.TypeA && qType != dns.TypeAAAA && qType != dns.TypeTXT {
return
}

Expand Down Expand Up @@ -530,45 +531,67 @@ RPC:
n := out.NodeServices.Node
edns := req.IsEdns0() != nil
addr := d.agent.TranslateAddress(datacenter, n.Address, n.TaggedAddresses)
records := d.formatNodeRecord(addr, req.Question[0].Name, qType, d.config.NodeTTL, edns)
records := d.formatNodeRecord(out.NodeServices.Node, addr, req.Question[0].Name, qType, d.config.NodeTTL, edns)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undo formatNodeRecord change from commit 76319f7

if records != nil {
resp.Answer = append(resp.Answer, records...)
}
}

// formatNodeRecord takes a Node and returns an A, AAAA, or CNAME record
func (d *DNSServer) formatNodeRecord(addr, qName string, qType uint16, ttl time.Duration, edns bool) (records []dns.RR) {
// encodeKVasRFC1464 encodes a key-value pair according to RFC1464
func encodeKVasRFC1464(key, value string) (txt string) {
// For details on these replacements c.f. https://www.ietf.org/rfc/rfc1464.txt
key = strings.Replace(key, "`", "``", -1)
key = strings.Replace(key, "=", "`=", -1)

// Backquote the leading spaces
leadingSpacesRE := regexp.MustCompile("^ +")
numLeadingSpaces := len(leadingSpacesRE.FindString(key))
key = leadingSpacesRE.ReplaceAllString(key, strings.Repeat("` ", numLeadingSpaces))

// Backquote the trailing spaces
trailingSpacesRE := regexp.MustCompile(" +$")
numTrailingSpaces := len(trailingSpacesRE.FindString(key))
key = trailingSpacesRE.ReplaceAllString(key, strings.Repeat("` ", numTrailingSpaces))

value = strings.Replace(value, "`", "``", -1)

return key + "=" + value
}

// formatNodeRecord takes a Node and returns an A, AAAA, TXT or CNAME record
func (d *DNSServer) formatNodeRecord(node *structs.Node, addr, qName string, qType uint16, ttl time.Duration, edns bool) (records []dns.RR) {
// Parse the IP
ip := net.ParseIP(addr)
var ipv4 net.IP
if ip != nil {
ipv4 = ip.To4()
}

switch {
case ipv4 != nil && (qType == dns.TypeANY || qType == dns.TypeA):
return []dns.RR{&dns.A{
records = append(records, &dns.A{
Copy link
Contributor Author

@sodre sodre Aug 5, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to append in order to support ANY queries when the node also has metadata key-values

Hdr: dns.RR_Header{
Name: qName,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: uint32(ttl / time.Second),
},
A: ip,
}}
})

case ip != nil && ipv4 == nil && (qType == dns.TypeANY || qType == dns.TypeAAAA):
return []dns.RR{&dns.AAAA{
records = append(records, &dns.AAAA{
Hdr: dns.RR_Header{
Name: qName,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: uint32(ttl / time.Second),
},
AAAA: ip,
}}
})

case ip == nil && (qType == dns.TypeANY || qType == dns.TypeCNAME ||
qType == dns.TypeA || qType == dns.TypeAAAA):
qType == dns.TypeA || qType == dns.TypeAAAA || qType == dns.TypeTXT):
// Get the CNAME
cnRec := &dns.CNAME{
Hdr: dns.RR_Header{
Expand All @@ -587,7 +610,7 @@ func (d *DNSServer) formatNodeRecord(addr, qName string, qType uint16, ttl time.
MORE_REC:
for _, rr := range more {
switch rr.Header().Rrtype {
case dns.TypeCNAME, dns.TypeA, dns.TypeAAAA:
case dns.TypeCNAME, dns.TypeA, dns.TypeAAAA, dns.TypeTXT:
records = append(records, rr)
extra++
if extra == maxRecurseRecords && !edns {
Expand All @@ -596,6 +619,25 @@ func (d *DNSServer) formatNodeRecord(addr, qName string, qType uint16, ttl time.
}
}
}

if node != nil && (qType == dns.TypeANY || qType == dns.TypeTXT) {
for key, value := range node.Meta {
txt := value
if !strings.HasPrefix(strings.ToLower(key), "rfc1035-") {
txt = encodeKVasRFC1464(key, value)
}
records = append(records, &dns.TXT{
Hdr: dns.RR_Header{
Name: qName,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: uint32(ttl / time.Second),
},
Txt: []string{txt},
})
}
}

return records
}

Expand Down Expand Up @@ -929,7 +971,7 @@ func (d *DNSServer) serviceNodeRecords(dc string, nodes structs.CheckServiceNode
handled[addr] = struct{}{}

// Add the node record
records := d.formatNodeRecord(addr, qName, qType, ttl, edns)
records := d.formatNodeRecord(node.Node, addr, qName, qType, ttl, edns)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undo formatNodeRecord change from commit 76319f7

if records != nil {
resp.Answer = append(resp.Answer, records...)
}
Expand Down Expand Up @@ -973,7 +1015,7 @@ func (d *DNSServer) serviceSRVRecords(dc string, nodes structs.CheckServiceNodes
}

// Add the extra record
records := d.formatNodeRecord(addr, srvRec.Target, dns.TypeANY, ttl, edns)
records := d.formatNodeRecord(node.Node, addr, srvRec.Target, dns.TypeANY, ttl, edns)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undo formatNodeRecord change from commit 76319f7

if len(records) > 0 {
// Use the node address if it doesn't differ from the service address
if addr == node.Node.Address {
Expand Down
146 changes: 141 additions & 5 deletions agent/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ func dnsA(src, dest string) *dns.A {
}
}

// dnsTXT returns a DNS TXT record struct
func dnsTXT(src string, txt []string) *dns.TXT {
return &dns.TXT{
Hdr: dns.RR_Header{
Name: dns.Fqdn(src),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
},
Txt: txt,
}
}

func TestRecursorAddr(t *testing.T) {
t.Parallel()
addr, err := recursorAddr("8.8.8.8")
Expand All @@ -89,6 +101,35 @@ func TestRecursorAddr(t *testing.T) {
}
}

func TestEncodeKVasRFC1464(t *testing.T) {
// Test cases are from rfc1464
type rfc1464Test struct {
key, value, internalForm, externalForm string
}
tests := []rfc1464Test{
{"color", "blue", "color=blue", "color=blue"},
{"equation", "a=4", "equation=a=4", "equation=a=4"},
{"a=a", "true", "a`=a=true", "a`=a=true"},
{"a\\=a", "false", "a\\`=a=false", "a\\`=a=false"},
{"=", "\\=", "`==\\=", "`==\\="},

{"string", "\"Cat\"", "string=\"Cat\"", "string=\"Cat\""},
{"string2", "`abc`", "string2=``abc``", "string2=``abc``"},
{"novalue", "", "novalue=", "novalue="},
{"a b", "c d", "a b=c d", "a b=c d"},
{"abc ", "123 ", "abc` =123 ", "abc` =123 "},

// Additional tests
{" abc", " 321", "` abc= 321", "` abc= 321"},
{"`a", "b", "``a=b", "``a=b"},
}

for _, test := range tests {
answer := encodeKVasRFC1464(test.key, test.value)
verify.Values(t, "internalForm", answer, test.internalForm)
}
}

func TestDNS_NodeLookup(t *testing.T) {
t.Parallel()
a := NewTestAgent(t.Name(), "")
Expand Down Expand Up @@ -300,6 +341,7 @@ func TestDNS_NodeLookup_CNAME(t *testing.T) {
Answer: []dns.RR{
dnsCNAME("www.google.com", "google.com"),
dnsA("google.com", "1.2.3.4"),
dnsTXT("google.com", []string{"my_txt_value"}),
},
})
defer recursor.Shutdown()
Expand Down Expand Up @@ -330,23 +372,117 @@ func TestDNS_NodeLookup_CNAME(t *testing.T) {
t.Fatalf("err: %v", err)
}

// Should have the service record, CNAME record + A record
if len(in.Answer) != 3 {
wantAnswer := []dns.RR{
&dns.CNAME{
Hdr: dns.RR_Header{Name: "google.node.consul.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 0, Rdlength: 0x10},
Target: "www.google.com.",
},
&dns.CNAME{
Hdr: dns.RR_Header{Name: "www.google.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Rdlength: 0x2},
Target: "google.com.",
},
&dns.A{
Hdr: dns.RR_Header{Name: "google.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Rdlength: 0x4},
A: []byte{0x1, 0x2, 0x3, 0x4}, // 1.2.3.4
},
&dns.TXT{
Hdr: dns.RR_Header{Name: "google.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Rdlength: 0xd},
Txt: []string{"my_txt_value"},
},
}
verify.Values(t, "answer", in.Answer, wantAnswer)
}

func TestDNS_NodeLookup_TXT(t *testing.T) {
cfg := TestConfig()
a := NewTestAgent(t.Name(), cfg)
defer a.Shutdown()

args := &structs.RegisterRequest{
Datacenter: "dc1",
Node: "google",
Address: "127.0.0.1",
NodeMeta: map[string]string{
"rfc1035-00": "value0",
"key0": "value1",
},
}

var out struct{}
if err := a.RPC("Catalog.Register", args, &out); err != nil {
t.Fatalf("err: %v", err)
}

m := new(dns.Msg)
m.SetQuestion("google.node.consul.", dns.TypeTXT)

c := new(dns.Client)
addr, _ := a.Config.ClientListener("", a.Config.Ports.DNS)
in, _, err := c.Exchange(m, addr.String())
if err != nil {
t.Fatalf("err: %v", err)
}

// Should have the 1 TXT record reply
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pls use verify.Values to compare full responses, if possible.

if len(in.Answer) != 2 {
t.Fatalf("Bad: %#v", in)
}

cnRec, ok := in.Answer[0].(*dns.CNAME)
txtRec, ok := in.Answer[0].(*dns.TXT)
if !ok {
t.Fatalf("Bad: %#v", in.Answer[0])
}
if cnRec.Target != "www.google.com." {
if len(txtRec.Txt) != 1 {
t.Fatalf("Bad: %#v", in.Answer[0])
}
if cnRec.Hdr.Ttl != 0 {
if txtRec.Txt[0] != "value0" && txtRec.Txt[0] != "key0=value1" {
t.Fatalf("Bad: %#v", in.Answer[0])
}
}

func TestDNS_NodeLookup_ANY(t *testing.T) {
cfg := TestConfig()
a := NewTestAgent(t.Name(), cfg)
defer a.Shutdown()

args := &structs.RegisterRequest{
Datacenter: "dc1",
Node: "bar",
Address: "127.0.0.1",
NodeMeta: map[string]string{
"key": "value",
},
}

var out struct{}
if err := a.RPC("Catalog.Register", args, &out); err != nil {
t.Fatalf("err: %v", err)
}

m := new(dns.Msg)
m.SetQuestion("bar.node.consul.", dns.TypeANY)

c := new(dns.Client)
addr, _ := a.Config.ClientListener("", a.Config.Ports.DNS)
in, _, err := c.Exchange(m, addr.String())
if err != nil {
t.Fatalf("err: %v", err)
}

wantAnswer := []dns.RR{
&dns.A{
Hdr: dns.RR_Header{Name: "bar.node.consul.", Rrtype: dns.TypeA, Class: dns.ClassINET, Rdlength: 0x4},
A: []byte{0x7f, 0x0, 0x0, 0x1}, // 127.0.0.1
},
&dns.TXT{
Hdr: dns.RR_Header{Name: "bar.node.consul.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Rdlength: 0xa},
Txt: []string{"key=value"},
},
}
verify.Values(t, "answer", in.Answer, wantAnswer)

}

func TestDNS_EDNS0(t *testing.T) {
t.Parallel()
a := NewTestAgent(t.Name(), "")
Expand Down
13 changes: 11 additions & 2 deletions website/source/docs/agent/dns.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ we can instead use `foo.node.consul.` This convention allows for terse
syntax where appropriate while supporting queries of nodes in remote
datacenters as necessary.

For a node lookup, the only records returned are A records containing
the IP address of the node.
For a node lookup, the only records returned are A and AAAA records
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added language for 'AAAA' records as well since it is in available in the code. Feel free to remove that in case it is not officially supported yet.

containing the IP address, and TXT records containing the
`node_meta` values of the node.

```text
$ dig @127.0.0.1 -p 8600 foo.node.consul ANY
Expand All @@ -76,11 +77,19 @@ $ dig @127.0.0.1 -p 8600 foo.node.consul ANY

;; ANSWER SECTION:
foo.node.consul. 0 IN A 10.1.10.12
foo.node.consul. 0 IN TXT "meta_key=meta_value"
foo.node.consul. 0 IN TXT "value only"


;; AUTHORITY SECTION:
consul. 0 IN SOA ns.consul. postmaster.consul. 1392836399 3600 600 86400 0
```

By default the TXT records value will match the node's metadata key-value
pairs according to [RFC1464](https://www.ietf.org/rfc/rfc1464.txt).
Alternatively, the TXT record will only include the node's metadata value when the
node's metadata key starts with `rfc1035-`.

## Service Lookups

A service lookup is used to query for service providers. Service queries support
Expand Down
2 changes: 2 additions & 0 deletions website/source/docs/agent/options.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ will exit with an error at startup.
- Metadata keys must contain only alphanumeric, `-`, and `_` characters.
- Metadata keys must not begin with the `consul-` prefix; that is reserved for internal use by Consul.
- Metadata values must be between 0 and 512 (inclusive) characters in length.
- Metadata values for keys begining with `rfc1035-` are encoded verbatim in DNS TXT requests, otherwise
the metadata kv-pair is encoded according [RFC1464](https://www.ietf.org/rfc/rfc1464.txt).

* <a name="_pid_file"></a><a href="#_pid_file">`-pid-file`</a> - This flag provides the file
path for the agent to store its PID. This is useful for sending signals (for example, `SIGINT`
Expand Down