Skip to content

Commit

Permalink
Add support for TXT records with multiple strings (BIND, ROUTE53) (St…
Browse files Browse the repository at this point in the history
…ackExchange#293)

* BIND: Support TXT records with multiple strings (StackExchange#289)
* ROUTE53: Add support for TXT records with multiple strings (StackExchange#292)
  • Loading branch information
Tom Limoncelli authored Jan 5, 2018
1 parent 5dec688 commit db1858a
Show file tree
Hide file tree
Showing 32 changed files with 485 additions and 180 deletions.
13 changes: 10 additions & 3 deletions docs/_functions/domain/TXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ parameters:
TXT adds an TXT record To a domain. The name should be the relative
label for the record. Use `@` for the domain apex.

The contents is a single string. While DNS permits multiple
strings in TXT records, that is not supported at this time.
The contents is either a single or multiple strings. To
specify multiple strings, include them in an array.

The string is a JavaScript string (quoted using single or double
TXT records with multiple strings are only supported by some
providers. DNSControl will produce a validation error if the
provider does not support multiple strings.

Each string is a JavaScript string (quoted using single or double
quotes). The (somewhat complex) quoting rules of the DNS protocol
will be done for you.

Expand All @@ -24,6 +28,9 @@ Modifers can be any number of [record modifiers](#record-modifiers) or json obje
D("example.com", REGISTRAR, ....,
TXT('@', '598611146-3338560'),
TXT('listserve', 'google-site-verification=12345'),
TXT('multiple', ['one', 'two', 'three']), // Multiple strings
TXT('quoted', 'any "quotes" and escapes? ugh; no worries!'),
TXT('_domainkey', 't=y; o=-;') // Escapes are done for you automatically.
);

{%endhighlight%}
Expand Down
45 changes: 41 additions & 4 deletions integrationTest/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func runTests(t *testing.T, prv providers.DNSServiceProvider, domainName string,
}
dom.Records = append(dom.Records, &rc)
}
models.Downcase(dom.Records)
models.PostProcessRecords(dom.Records)
dom2, _ := dom.Copy()
// get corrections for first time
corrections, err := prv.GetDomainCorrections(dom)
Expand Down Expand Up @@ -237,7 +237,17 @@ func srv(name string, priority, weight, port uint16, target string) *rec {
}

func txt(name, target string) *rec {
return makeRec(name, target, "TXT")
// FYI: This must match the algorithm in pkg/js/helpers.js TXT.
r := makeRec(name, target, "TXT")
r.TxtStrings = []string{target}
return r
}

func txtmulti(name string, target []string) *rec {
// FYI: This must match the algorithm in pkg/js/helpers.js TXT.
r := makeRec(name, target[0], "TXT")
r.TxtStrings = target
return r
}

func caa(name string, tag string, flag uint8, target string) *rec {
Expand Down Expand Up @@ -427,15 +437,42 @@ func makeTests(t *testing.T) []*TestCase {
)
}

// Case
// TXT (single)
tests = append(tests, tc("Empty"),
// TXT
tc("Empty"),
tc("Create a TXT", txt("foo", "simple")),
tc("Change a TXT", txt("foo", "changed")),
tc("Empty"),
tc("Create a TXT with spaces", txt("foo", "with spaces")),
tc("Change a TXT with spaces", txt("foo", "with whitespace")),
tc("Create 1 TXT as array", txtmulti("foo", []string{"simple"})),
)

// TXTMulti
if !providers.ProviderHasCabability(*providerToRun, providers.CanUseTXTMulti) {
t.Log("Skipping TXTMulti Tests because provider does not support them")
} else {
tests = append(tests,
tc("Empty"),
tc("Create TXTMulti 1",
txtmulti("foo1", []string{"simple"}),
),
tc("Create TXTMulti 2",
txtmulti("foo1", []string{"simple"}),
txtmulti("foo2", []string{"one", "two"}),
),
tc("Create TXTMulti 3",
txtmulti("foo1", []string{"simple"}),
txtmulti("foo2", []string{"one", "two"}),
txtmulti("foo3", []string{"eh", "bee", "cee"}),
),
tc("Change TXTMulti",
txtmulti("foo1", []string{"dimple"}),
txtmulti("foo2", []string{"fun", "two"}),
txtmulti("foo3", []string{"eh", "bzz", "cee"}),
),
)
}

return tests
}
2 changes: 1 addition & 1 deletion integrationTest/zones/example.com.zone
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
$TTL 300
@ IN SOA DEFAULT_NOT_SET. DEFAULT_NOT_SET. 2017091830 3600 600 604800 1440
@ IN SOA DEFAULT_NOT_SET. DEFAULT_NOT_SET. 2018010281 3600 600 604800 1440
IN NS ns1.otherdomain.tld.
IN NS ns2.otherdomain.tld.
21 changes: 19 additions & 2 deletions models/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ type RecordConfig struct {
TlsaUsage uint8 `json:"tlsausage,omitempty"`
TlsaSelector uint8 `json:"tlsaselector,omitempty"`
TlsaMatchingType uint8 `json:"tlsamatchingtype,omitempty"`
TxtStrings []string `json:"txtstrings,omitempty"` // TxtStrings stores all strings (including the first). Target stores only the first one.

CombinedTarget bool `json:"-"`

Expand Down Expand Up @@ -247,7 +248,7 @@ func (rc *RecordConfig) ToRR() dns.RR {
rr.(*dns.TLSA).Selector = rc.TlsaSelector
rr.(*dns.TLSA).Certificate = rc.Target
case dns.TypeTXT:
rr.(*dns.TXT).Txt = []string{rc.Target}
rr.(*dns.TXT).Txt = rc.TxtStrings
default:
panic(fmt.Sprintf("ToRR: Unimplemented rtype %v", rc.Type))
// We panic so that we quickly find any switch statements
Expand Down Expand Up @@ -275,6 +276,12 @@ func (r Records) Grouped() map[RecordKey]Records {
return groups
}

// PostProcessRecords does any post-processing of the downloaded DNS records.
func PostProcessRecords(recs []*RecordConfig) {
Downcase(recs)
fixTxt(recs)
}

// Downcase converts all labels and targets to lowercase in a list of RecordConfig.
func Downcase(recs []*RecordConfig) {
for _, r := range recs {
Expand All @@ -292,6 +299,17 @@ func Downcase(recs []*RecordConfig) {
return
}

// fixTxt fixes TXT records generated by providers that do not understand CanUseTXTMulti.
func fixTxt(recs []*RecordConfig) {
for _, r := range recs {
if r.Type == "TXT" {
if len(r.TxtStrings) == 0 {
r.TxtStrings = []string{r.Target}
}
}
}
}

type RecordKey struct {
Name string
Type string
Expand Down Expand Up @@ -453,7 +471,6 @@ func (dc *DomainConfig) CombineCAAs() {
panic(pm)
}
rec.Target = rec.Content()
fmt.Printf("DEBUG: NEW TARGET: %v\n", rec.Target)
rec.CombinedTarget = true
}
}
Expand Down
57 changes: 57 additions & 0 deletions models/txt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package models

import "strings"

// SetTxt sets the value of a TXT record to s.
func (rc *RecordConfig) SetTxt(s string) {
rc.Target = s
rc.TxtStrings = []string{s}
}

// SetTxts sets the value of a TXT record to the list of strings s.
func (rc *RecordConfig) SetTxts(s []string) {
rc.Target = s[0]
rc.TxtStrings = s
}

// SetTxtParse sets the value of TXT record if the list of strings is combined into one string.
// `foo` -> []string{"foo"}
// `"foo"` -> []string{"foo"}
// `"foo" "bar"` -> []string{"foo" "bar"}
func (rc *RecordConfig) SetTxtParse(s string) {
rc.SetTxts(ParseQuotedTxt(s))
}

// IsQuoted returns true if the string starts and ends with a double quote.
func IsQuoted(s string) bool {
if s == "" {
return false
}
if len(s) < 2 {
return false
}
if s[0] == '"' && s[len(s)-1] == s[0] {
return true
}
return false
}

// StripQuotes returns the string with the starting and ending quotes removed.
func StripQuotes(s string) string {
if IsQuoted(s) {
return s[1 : len(s)-1]
}
return s
}

// ParseQuotedTxt returns the individual strings of a combined quoted string.
// `foo` -> []string{"foo"}
// `"foo"` -> []string{"foo"}
// `"foo" "bar"` -> []string{"foo" "bar"}
// NOTE: it is assumed there is exactly one space between the quotes.
func ParseQuotedTxt(s string) []string {
if !IsQuoted(s) {
return []string{s}
}
return strings.Split(StripQuotes(s), `" "`)
}
71 changes: 71 additions & 0 deletions models/txt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package models

import (
"testing"
)

func TestIsQuoted(t *testing.T) {
tests := []struct {
d1 string
e1 bool
}{
{``, false},
{`foo`, false},
{`""`, true},
{`"a"`, true},
{`"bb"`, true},
{`"ccc"`, true},
{`"aaa" "bbb"`, true},
}
for i, test := range tests {
r := IsQuoted(test.d1)
if r != test.e1 {
t.Errorf("%v: expected (%v) got (%v)", i, test.e1, r)
}
}
}

func TestStripQuotes(t *testing.T) {
tests := []struct {
d1 string
e1 string
}{
{``, ``},
{`a`, `a`},
{`bb`, `bb`},
{`ccc`, `ccc`},
{`dddd`, `dddd`},
{`"A"`, `A`},
{`"BB"`, `BB`},
{`"CCC"`, `CCC`},
{`"DDDD"`, `DDDD`},
{`"EEEEE"`, `EEEEE`},
{`"aaa" "bbb"`, `aaa" "bbb`},
}
for i, test := range tests {
r := StripQuotes(test.d1)
if r != test.e1 {
t.Errorf("%v: expected (%v) got (%v)", i, test.e1, r)
}
}
}

func TestSetTxtParse(t *testing.T) {
tests := []struct {
d1 string
e1 string
e2 []string
}{
{``, ``, []string{``}},
{`foo`, `foo`, []string{`foo`}},
{`"foo"`, `foo`, []string{`foo`}},
{`"aaa" "bbb"`, `aaa`, []string{`aaa`, `bbb`}},
}
for i, test := range tests {
x := &RecordConfig{Type: "TXT"}
x.SetTxtParse(test.d1)
if x.Target != test.e1 {
t.Errorf("%v: expected Target=(%v) got (%v)", i, test.e1, x.Target)
}
}
}
26 changes: 25 additions & 1 deletion pkg/js/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,32 @@ var TLSA = recordBuilder('TLSA', {
},
});

function isStringOrArray(x) {
return _.isString(x) || _.isArray(x);
}

// TXT(name,target, recordModifiers...)
var TXT = recordBuilder('TXT');
var TXT = recordBuilder("TXT", {
args: [["name", _.isString], ["target", isStringOrArray]],
transform: function(record, args, modifiers) {
record.name = args.name;
// Store the strings twice:
// .target is the first string
// .txtstrings is the individual strings.
// NOTE: If there are more than 1 string, providers should only access
// .txtstrings, thus it doesn't matter what we store in .target.
// However, by storing the first string there, it improves backwards
// compatibility when the len(array) == 1 and (intentionally) breaks
// broken providers early in the integration tests.
if (_.isString(args.target)) {
record.target = args.target;
record.txtstrings = [args.target];
} else {
record.target = args.target[0]
record.txtstrings = args.target;
}
}
});

// MX(name,priority,target, recordModifiers...)
var MX = recordBuilder('MX', {
Expand Down
5 changes: 3 additions & 2 deletions pkg/js/parse_tests/016-backslash.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
"type": "TXT",
"name": "_dmarc",
"target": "v=DMARC1\\; p=reject\\; sp=reject\\; pct=100\\; rua=mailto:xx...@yyyy.com\\; ruf=mailto:xx...@yyyy.com\\; fo=1",
"ttl": 300
"ttl": 300,
"txtstrings":["v=DMARC1\\; p=reject\\; sp=reject\\; pct=100\\; rua=mailto:xx...@yyyy.com\\; ruf=mailto:xx...@yyyy.com\\; fo=1"]
}
]
}
]
}
}
6 changes: 6 additions & 0 deletions pkg/js/parse_tests/017-txt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
D("foo.com","none"
, TXT("@","simple")
, TXT("@",["one"])
, TXT("@",["bonie", "clyde"])
, TXT("@",["straw", "wood", "brick"])
);
37 changes: 37 additions & 0 deletions pkg/js/parse_tests/017-txt.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"registrars": [],
"dns_providers": [],
"domains": [
{
"name": "foo.com",
"registrar": "none",
"dnsProviders": {},
"records": [
{
"type": "TXT",
"name": "@",
"target": "simple",
"txtstrings": [ "simple" ]
},
{
"type": "TXT",
"name": "@",
"target": "one",
"txtstrings": [ "one" ]
},
{
"type": "TXT",
"name": "@",
"target": "bonie",
"txtstrings": [ "bonie", "clyde" ]
},
{
"type": "TXT",
"name": "@",
"target": "straw",
"txtstrings": [ "straw", "wood", "brick" ]
}
]
}
]
}
Loading

0 comments on commit db1858a

Please # to comment.