1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

MAINT: Update TXT docs, suggest not using TxtNoLen255 (#1548)

* suggest not using TxtNoLen255

* Rename functions

* wip!

* fixing!
This commit is contained in:
Tom Limoncelli
2022-06-20 11:34:05 -04:00
committed by GitHub
parent 691710d39e
commit 959f721c04
7 changed files with 159 additions and 64 deletions

View File

@ -1,8 +1,10 @@
package models
import (
"encoding/csv"
"fmt"
"strings"
"github.com/miekg/dns"
)
// IsQuoted returns true if the string starts and ends with a double quote.
@ -32,13 +34,14 @@ func StripQuotes(s string) string {
// `foo` -> []string{"foo"}
// `"foo"` -> []string{"foo"}
// `"foo" "bar"` -> []string{"foo", "bar"}
// NOTE: it is assumed there is exactly one space between the quotes.
// `"f"oo" "bar"` -> []string{`f"oo`, "bar"}
// NOTE: It is assumed there is exactly one space between the quotes.
// NOTE: This doesn't handle escaped quotes.
// NOTE: You probably want to use ParseQuotedFields() for RFC 1035-compliant quoting.
func ParseQuotedTxt(s string) []string {
if !IsQuoted(s) {
return []string{s}
}
// TODO(tlim): Consider using r, err := ParseQuotedFields(s)
return strings.Split(StripQuotes(s), `" "`)
}
@ -48,13 +51,12 @@ func ParseQuotedFields(s string) ([]string, error) {
// Parse according to RFC1035 zonefile specifications.
// "foo" -> one string: `foo``
// "foo" "bar" -> two strings: `foo` and `bar`
// Quotes are escaped with \"
// Implementation note:
// Fields are space-separated but a field might be quoted. This is,
// essentially, a CSV where spaces are the field separator (not
// commas). Therefore, we use the CSV parser. See https://stackoverflow.com/a/47489846/71978
r := csv.NewReader(strings.NewReader(s))
r.Comma = ' ' // space
return r.Read()
// The dns package doesn't expose the quote parser. Therefore we create a TXT record and extract the strings.
rr, err := dns.NewRR("example.com. IN TXT " + s)
if err != nil {
return nil, fmt.Errorf("could not parse %q TXT: %w", s, err)
}
return rr.(*dns.TXT).Txt, nil
}

View File

@ -50,23 +50,20 @@ func TestStripQuotes(t *testing.T) {
}
}
func TestSetTxtParse(t *testing.T) {
func TestParseQuotedTxt(t *testing.T) {
tests := []struct {
d1 string
e1 string
e2 []string
}{
{`foo`, `foo`, []string{`foo`}},
{`"foo"`, `foo`, []string{`foo`}},
{`"foo bar"`, `foo bar`, []string{`foo bar`}},
{`foo bar`, `foo bar`, []string{`foo bar`}},
{`"aaa" "bbb"`, `aaa`, []string{`aaa`, `bbb`}},
{`foo`, []string{`foo`}},
{`"foo"`, []string{`foo`}},
{`"foo bar"`, []string{`foo bar`}},
{`foo bar`, []string{`foo bar`}},
{`"aaa" "bbb"`, []string{`aaa`, `bbb`}},
{`"a"a" "bbb"`, []string{`a"a`, `bbb`}},
}
for i, test := range tests {
ls := ParseQuotedTxt(test.d1)
if ls[0] != test.e1 {
t.Errorf("%v: expected Target=(%v) got (%v)", i, test.e1, ls[0])
}
if len(ls) != len(test.e2) {
t.Errorf("%v: expected TxtStrings=(%v) got (%v)", i, test.e2, ls)
}
@ -86,6 +83,7 @@ func TestParseQuotedFields(t *testing.T) {
{`1 2 3`, []string{`1`, `2`, `3`}},
{`1 "2" 3`, []string{`1`, `2`, `3`}},
{`1 2 "three 3"`, []string{`1`, `2`, `three 3`}},
{`1 2 "qu\"te" "4"`, []string{`1`, `2`, `qu\"te`, `4`}},
{`0 issue "letsencrypt.org; validationmethods=dns-01; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234"`, []string{`0`, `issue`, `letsencrypt.org; validationmethods=dns-01; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234`}},
}
for i, test := range tests {

View File

@ -1,39 +1,113 @@
package models
import "strings"
import (
"fmt"
"strings"
)
/*
Sadly many providers handle TXT records in strange and non-compliant ways.
Sadly many providers handle TXT records in strange and non-compliant
ways. DNSControl has to handle all of them. Over the years we've
tried many things. This explain the current state of the code.
The variations we've seen:
What are some of these variations?
* a TXT record is a list of strings, each 255-octets or fewer.
* a TXT record is a list of strings, all but the last will be 255 octets in length.
* a TXT record is a string less than 256 octets.
* a TXT record is any length, but we'll split it into 255-octet chunks behind the scenes.
* The RFCs say that a TXT record is a series of strings, each 255-octets
or fewer. Yet, most provider APIs only support a single string which
is split into 255-octetl chunks behind the scenes. Some only support
a single string that is 255-octets or less.
DNSControl stores the string as the user specified, then lets the
provider work out how to handle the given input. There are two
opportunties to work with the data:
* The RFCs don't say much about the content of the strings. Some
providers accept any octet, some only accept ASCII-printable chars,
some get confused by TXT records that include backticks, quotes, or
whitespace at the end of the string.
1. The provider's AuditRecords() function is called to permit
the provider to return an error if it won't be able to handle the
contents. For example, it might detect that the string contains a char
the provider doesn't support (for example, a backtick). This auditing
is done without any communication to the provider's API. This allows
such errors to be detected at the "dnscontrol check" stage. 2. When
performing corrections (GetDomainCorrections()), the provider can slice
and dice the user's input however they want.
DNSControl has tried many different ways to handle all these
variations over the years. This is what we found works best:
* If the user input is a list of strings:
* The strings are stored in RecordConfig.TxtStrings
* If the user input is a single string:
* The strings are stored in RecordConfig.TxtStrings[0]
Principle 1. Store the string as the user input it.
In both cases, the .Target stores a string that can be used in error
messages and other UI messages. This string should not be used by the
provider in any other way because it has been modified from the user's
original input.
DNSControl stores the string as the user specified in dnsconfig.js.
The user can specify a string of any length, or many individual
strings of any length.
No matter how the user presented the data in dnsconfig.js, the data is
stored as a list of strings (RecordConfig.TxtStrings []string). If
they input 1 string, the list has one element. If the user input many
individual strings, the list is copied into .TxtStrings.
When we store the data in .TxtStrings there is no length checking. The data is not manipulated.
Principle 2. When downloading zone records, receive the data as appropriate.
When the API returns a TXT record, the provider's code must properly
store it in the .TxtStrings field of RecordConfig.
We've found most APIs return TXT strings in one of three ways:
* The API returns a single string: use RecordConfig.SetTargetTXT().
* The API returns multiple strings: use RecordConfig.SetTargetTXTs().
* (THIS IS RARE) The API returns a single string that must be parsed
into multiple strings: The provider is responsible for the
parsing. However, usually the format is "quoted like in RFC 1035"
which is vague, but we've implemented it as
RecordConfig.SetTargetTXTfromRFC1035Quoted().
If the format is something else, please write the parser as a separate
function and write unit tests based on actual data received from the
API.
Principle 3. When sending TXT records to the API, send what the API expects.
The provider's code must decide how to take the list of strings in
.TxtStrings and present them to the API.
Most providers fall into one of these categories:
* If the API expects one long string, the provider code joins all
the smaller strings and sends one big string. Use the helper
function RecordConfig.GetTargetTXTJoined()
* If the API expects many strings of any size, the provider code
sends the individual strings. Those strings are accessed as
the array RecordConfig.TxtStrings
* (THIS IS RARE) If the API expects multiple strings to be sent as
one long string, quoted RFC 1025-style, call
RecordConfig.GetTargetRFC1035Quoted() and send that string.
Note: If the API expects many strings, each 255-octets or smaller, the
provider code must split the longer strings into smaller strings. The
helper function txtutil.SplitSingleLongTxt(dc.Records) will iterate
over all TXT records and split out any strings longer than 255 octets.
Call this once in GetDomainCorrections(). (Yes, this violates
Principle 1, but we decided it is best to do it once, than provide a
getter that would re-split the strings on every call.)
Principle 4. Providers can communicate back to DNSControl strings they can't handle.
As mentioned before, some APIs reject TXT records for various reasons:
Illegal chars, whitespace at the end, etc. We can't make a flag for
every variation. Instead we call the provider's AuditRecords()
function and it reports if there are any records that it can't
process.
We've provided many helper functions to make this easier. Look at any
of the providers/.../auditrecord.go` files for examples.
The integration tests call AuditRecords() to skip any tests that we
know will fail. If one of the integration tests is failing, it is
often better to update AuditRecords() than to try to figure out why,
for example, the provider doesn't support backticks in strings. Don't
spend a lot of effort trying to fix situations that are rare or will
not appear in real-world situations.
Companies do update their APIs occasionally. You might want to try
eliminating the checks one at a time to see if the API has improved.
Don't feel obligated to do this more than once a year.
Conclusion:
When we follow these 4 principles, and stick with the helper functions
provided, we're able to handle all the variations.
*/
@ -78,20 +152,45 @@ func (rc *RecordConfig) GetTargetTXTJoined() string {
return strings.Join(rc.TxtStrings, "")
}
// SetTargetTXTString is like SetTargetTXT but accepts one big string,
// which must be parsed into one or more strings based on how it is quoted.
// SetTargetTXTString is like SetTargetTXTs but accepts one big string,
// which is parsed into individual strings.
// Ex: foo << 1 string
// foo bar << 1 string
// "foo bar" << 1 string
// "foo" "bar" << 2 strings
// "f"oo" "bar" << 2 strings, one has a quote in it
//
// BUG: This function doesn't handle escaped quotes ("like \" this").
//
// FIXME(tlim): This function is badly named. It obscures the fact
// that the string is parsed for quotes and should only be used for returns TXTMulti.
// Deprecated: Use SetTargetTXTfromRFC1035Quoted instead.
// that the string is parsed for quotes and stores a list of strings.
//
// Deprecated: This function has a confusing name. Most providers API
// return a single string, in which case you should use
// SetTargetTXT(). If your provider returns multiple strings, use
// SetTargetTXTs(). If your provider returns a single string that
// must be parsed to extract the individual strings, use
// SetTargetTXTfromRFC1035Quoted(). Sadly we have not figured out
// an integration test that will fail if you chose the wrong function.
// As a result, we recommend trying SetTargetTXT() before you try
// SetTargetTXTfromRFC1035Quoted().
func (rc *RecordConfig) SetTargetTXTString(s string) error {
return rc.SetTargetTXTs(ParseQuotedTxt(s))
}
// SetTargetTXTfromRFC1035Quoted parses a series of quoted strings
// and sets .TxtStrings based on the result.
// Note: Most APIs do notThis is rarely used. Try using SetTargetTXT() first.
// Ex: "foo" << 1 string
// "foo bar" << 1 string
// "foo" "bar" << 2 strings
// foo << error. No quotes! Did you intend to use SetTargetTXT?
func (rc *RecordConfig) SetTargetTXTfromRFC1035Quoted(s string) error {
if s != "" && s[0] != '"' {
// If you get this error, it is likely that you should use
// SetTargetTXT() instead of SetTargetTXTfromRFC1035Quoted().
return fmt.Errorf("non-quoted string used with SetTargetTXTfromRFC1035Quoted: (%s)", s)
}
many, err := ParseQuotedFields(s)
if err != nil {
return err

View File

@ -57,8 +57,9 @@ func TxtNoDoubleQuotes(records []*models.RecordConfig) error {
return nil
}
// TxtNoLen255 audits TXT records for strings exactly 255 octets long.
func TxtNoLen255(records []*models.RecordConfig) error {
// TxtNoStringsExactlyLen255 audits TXT records for strings exactly 255 octets long.
// This is rare; you probably want to use TxtNoLongStrings() instead.
func TxtNoStringsExactlyLen255(records []*models.RecordConfig) error {
for _, rc := range records {
if rc.HasFormatIdenticalToTXT() { // TXT and similar:
@ -73,8 +74,8 @@ func TxtNoLen255(records []*models.RecordConfig) error {
return nil
}
// TxtNoLongStrings audits TXT records for strings that are >255 octets.
func TxtNoLongStrings(records []*models.RecordConfig) error {
// TxtNoStringsLen256orLonger audits TXT records for strings that are >255 octets.
func TxtNoStringsLen256orLonger(records []*models.RecordConfig) error {
for _, rc := range records {
if rc.HasFormatIdenticalToTXT() { // TXT and similar:

View File

@ -20,7 +20,7 @@ func AuditRecords(records []*models.RecordConfig) error {
return err
} // Needed as of 2022-06-10
if err := recordaudit.TxtNoLen255(records); err != nil {
if err := recordaudit.TxtNoStringsLen256orLonger(records); err != nil {
return err
} // Needed as of 2022-06-10

View File

@ -14,7 +14,7 @@ func AuditRecords(records []*models.RecordConfig) error {
}
// Still needed as of 2021-03-01
if err := recordaudit.TxtNoLen255(records); err != nil {
if err := recordaudit.TxtNoStringsExactlyLen255(records); err != nil {
return err
}
// Still needed as of 2021-03-01

View File

@ -14,7 +14,7 @@ func AuditRecords(records []*models.RecordConfig) error {
}
// Still needed as of 2021-03-01
if err := recordaudit.TxtNoLongStrings(records); err != nil {
if err := recordaudit.TxtNoStringsLen256orLonger(records); err != nil {
return err
}
@ -38,10 +38,5 @@ func AuditRecords(records []*models.RecordConfig) error {
}
// Still needed as of 2021-03-01
if err := recordaudit.TxtNoLen255(records); err != nil {
return err
}
// Still needed as of 2021-03-01
return nil
}