initial commit
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
// Package validate holds DNS name and record validation shared by the web UI,
|
||||
// the REST API and the zone-file importer. Keeping it in one place means the
|
||||
// three entry points cannot drift apart on what they accept.
|
||||
package validate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// MaxNameLength is the DNS wire-format limit for a fully qualified name.
|
||||
const MaxNameLength = 253
|
||||
|
||||
// MaxLabelLength is the wire-format limit for a single label.
|
||||
const MaxLabelLength = 63
|
||||
|
||||
// NormaliseFQDN lowercases a name and ensures a single trailing dot.
|
||||
// It returns an error describing what is wrong rather than a bare "invalid".
|
||||
func NormaliseFQDN(name string) (string, error) {
|
||||
n := strings.TrimSpace(name)
|
||||
if n == "" {
|
||||
return "", errors.New("name must not be empty")
|
||||
}
|
||||
if n == "." {
|
||||
return ".", nil
|
||||
}
|
||||
n = strings.ToLower(n)
|
||||
n = strings.TrimSuffix(n, ".")
|
||||
if n == "" {
|
||||
return "", errors.New("name must not be empty")
|
||||
}
|
||||
if len(n)+1 > MaxNameLength {
|
||||
return "", fmt.Errorf("name is %d characters, which exceeds the %d character DNS limit",
|
||||
len(n)+1, MaxNameLength)
|
||||
}
|
||||
for _, label := range strings.Split(n, ".") {
|
||||
if err := validateLabel(label, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return n + ".", nil
|
||||
}
|
||||
|
||||
// NormaliseDomain lowercases a domain and strips the trailing dot. This is the
|
||||
// form stored in blacklists and allowlists.
|
||||
func NormaliseDomain(name string) (string, error) {
|
||||
fqdn, err := NormaliseFQDN(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSuffix(fqdn, "."), nil
|
||||
}
|
||||
|
||||
// validateLabel checks one label. Wildcard "*" is only legal as the leftmost
|
||||
// label, which the caller signals with allowWildcard.
|
||||
func validateLabel(label string, allowWildcard bool) error {
|
||||
if label == "" {
|
||||
return errors.New("name contains an empty label (two dots in a row, or a leading dot)")
|
||||
}
|
||||
if label == "*" {
|
||||
if allowWildcard {
|
||||
return nil
|
||||
}
|
||||
return errors.New("wildcard \"*\" is only allowed as the leftmost label")
|
||||
}
|
||||
if len(label) > MaxLabelLength {
|
||||
return fmt.Errorf("label %q is %d characters, which exceeds the %d character limit",
|
||||
label, len(label), MaxLabelLength)
|
||||
}
|
||||
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return fmt.Errorf("label %q must not start or end with a hyphen", label)
|
||||
}
|
||||
for _, r := range label {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == '-', r == '_':
|
||||
// Underscores are not legal in host names but are required by SRV,
|
||||
// TLSA, DKIM and ACME challenge records, so we permit them.
|
||||
default:
|
||||
return fmt.Errorf("label %q contains the invalid character %q; "+
|
||||
"use punycode for internationalised names", label, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormaliseZoneName validates and normalises a zone apex name.
|
||||
func NormaliseZoneName(name string) (string, error) {
|
||||
n := strings.TrimSpace(name)
|
||||
if n == "" {
|
||||
return "", errors.New("zone name must not be empty")
|
||||
}
|
||||
if strings.HasPrefix(n, "*") {
|
||||
return "", errors.New("a zone name may not be a wildcard")
|
||||
}
|
||||
fqdn, err := NormaliseFQDN(n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fqdn == "." {
|
||||
return "", errors.New("the root zone cannot be served by this application")
|
||||
}
|
||||
return fqdn, nil
|
||||
}
|
||||
|
||||
// NormaliseRecordName normalises a record name that is relative to a zone apex.
|
||||
// The apex itself is stored as "@". A name given as a full FQDN inside the zone
|
||||
// is converted to its relative form.
|
||||
func NormaliseRecordName(name, zone string) (string, error) {
|
||||
n := strings.TrimSpace(strings.ToLower(name))
|
||||
if n == "" || n == "@" {
|
||||
return "@", nil
|
||||
}
|
||||
// Absolute name: it must fall inside the zone.
|
||||
if strings.HasSuffix(n, ".") {
|
||||
if n == zone {
|
||||
return "@", nil
|
||||
}
|
||||
if !strings.HasSuffix(n, "."+zone) && !strings.HasSuffix(n, zone) {
|
||||
return "", fmt.Errorf("name %q is not inside zone %s", name, zone)
|
||||
}
|
||||
n = strings.TrimSuffix(n, "."+zone)
|
||||
n = strings.TrimSuffix(n, ".")
|
||||
if n == "" {
|
||||
return "@", nil
|
||||
}
|
||||
}
|
||||
labels := strings.Split(n, ".")
|
||||
for i, l := range labels {
|
||||
if err := validateLabel(l, i == 0); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if len(n)+1+len(zone) > MaxNameLength {
|
||||
return "", fmt.Errorf("the fully qualified name would exceed the %d character DNS limit", MaxNameLength)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// AbsoluteName joins a relative record name with its zone apex.
|
||||
func AbsoluteName(name, zone string) string {
|
||||
if name == "@" || name == "" {
|
||||
return zone
|
||||
}
|
||||
if strings.HasSuffix(name, ".") {
|
||||
return name
|
||||
}
|
||||
return name + "." + zone
|
||||
}
|
||||
|
||||
// IsSubdomain reports whether child is equal to or below parent. Both must be
|
||||
// normalised FQDNs.
|
||||
func IsSubdomain(child, parent string) bool {
|
||||
if parent == "." {
|
||||
return true
|
||||
}
|
||||
if child == parent {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(child, "."+parent)
|
||||
}
|
||||
|
||||
// --- Reverse zones ------------------------------------------------------
|
||||
|
||||
// ReverseZone converts a CIDR block into the in-addr.arpa or ip6.arpa zone
|
||||
// that covers it.
|
||||
//
|
||||
// DNS reverse delegation only happens on octet boundaries for IPv4 and nibble
|
||||
// boundaries for IPv6. When the supplied prefix is finer than that, the zone
|
||||
// for the nearest enclosing boundary is returned along with an explanatory
|
||||
// note, so the UI can tell the operator what it actually created.
|
||||
func ReverseZone(cidr string) (zone string, note string, err error) {
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil {
|
||||
addr, aerr := netip.ParseAddr(strings.TrimSpace(cidr))
|
||||
if aerr != nil {
|
||||
return "", "", fmt.Errorf("%q is not a valid CIDR block or IP address", cidr)
|
||||
}
|
||||
p = netip.PrefixFrom(addr, addr.BitLen())
|
||||
}
|
||||
p = p.Masked()
|
||||
|
||||
if p.Addr().Is4() {
|
||||
bits := p.Bits()
|
||||
use := (bits / 8) * 8
|
||||
if use == 0 {
|
||||
return "", "", errors.New("an IPv4 reverse zone needs at least a /8 prefix")
|
||||
}
|
||||
if use != bits {
|
||||
note = fmt.Sprintf("Reverse DNS delegation for IPv4 happens on octet boundaries, "+
|
||||
"so /%d was rounded to the enclosing /%d zone.", bits, use)
|
||||
}
|
||||
octets := p.Addr().As4()
|
||||
var labels []string
|
||||
for i := use/8 - 1; i >= 0; i-- {
|
||||
labels = append(labels, fmt.Sprintf("%d", octets[i]))
|
||||
}
|
||||
return strings.Join(labels, ".") + ".in-addr.arpa.", note, nil
|
||||
}
|
||||
|
||||
bits := p.Bits()
|
||||
use := (bits / 4) * 4
|
||||
if use == 0 {
|
||||
return "", "", errors.New("an IPv6 reverse zone needs at least a /4 prefix")
|
||||
}
|
||||
if use != bits {
|
||||
note = fmt.Sprintf("Reverse DNS delegation for IPv6 happens on nibble boundaries, "+
|
||||
"so /%d was rounded to the enclosing /%d zone.", bits, use)
|
||||
}
|
||||
nibbles := ipv6Nibbles(p.Addr())
|
||||
var labels []string
|
||||
for i := use/4 - 1; i >= 0; i-- {
|
||||
labels = append(labels, nibbles[i])
|
||||
}
|
||||
return strings.Join(labels, ".") + ".ip6.arpa.", note, nil
|
||||
}
|
||||
|
||||
func ipv6Nibbles(a netip.Addr) []string {
|
||||
b := a.As16()
|
||||
out := make([]string, 0, 32)
|
||||
const hex = "0123456789abcdef"
|
||||
for _, x := range b {
|
||||
out = append(out, string(hex[x>>4]), string(hex[x&0x0f]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PTRName returns the full reverse DNS name for a single IP address, e.g.
|
||||
// 192.0.2.10 becomes 10.2.0.192.in-addr.arpa.
|
||||
func PTRName(ip string) (string, error) {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(ip))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%q is not a valid IP address", ip)
|
||||
}
|
||||
name, err := dns.ReverseAddr(addr.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot build a reverse name for %s: %w", ip, err)
|
||||
}
|
||||
return strings.ToLower(name), nil
|
||||
}
|
||||
|
||||
// ReverseZoneKindForCIDR reports which reverse zone family a CIDR belongs to.
|
||||
func ReverseZoneKindForCIDR(cidr string) (string, error) {
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil {
|
||||
addr, aerr := netip.ParseAddr(strings.TrimSpace(cidr))
|
||||
if aerr != nil {
|
||||
return "", fmt.Errorf("%q is not a valid CIDR block or IP address", cidr)
|
||||
}
|
||||
p = netip.PrefixFrom(addr, addr.BitLen())
|
||||
}
|
||||
if p.Addr().Is4() {
|
||||
return "reverse4", nil
|
||||
}
|
||||
return "reverse6", nil
|
||||
}
|
||||
|
||||
// ZoneKindForName infers the zone family from an apex name.
|
||||
func ZoneKindForName(zone string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(zone, ".in-addr.arpa."):
|
||||
return "reverse4"
|
||||
case strings.HasSuffix(zone, ".ip6.arpa."):
|
||||
return "reverse6"
|
||||
default:
|
||||
return "forward"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Field describes one input in a type-specific record editor. The web UI builds
|
||||
// its forms from this metadata, so adding a record type here gives it a proper
|
||||
// editor without writing a new template.
|
||||
type Field struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"` // text, number, textarea, select
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Help string `json:"help,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Quote bool `json:"quote,omitempty"` // rdata field must be a quoted string
|
||||
Options []string `json:"options,omitempty"`
|
||||
Width int `json:"width,omitempty"` // Bootstrap column width, 12-grid
|
||||
}
|
||||
|
||||
// TypeInfo describes a record type and its editor.
|
||||
type TypeInfo struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Fields []Field `json:"fields"`
|
||||
Common bool `json:"common"`
|
||||
}
|
||||
|
||||
func num(key, label, placeholder, help string, width int) Field {
|
||||
return Field{Key: key, Label: label, Type: "number", Placeholder: placeholder,
|
||||
Help: help, Required: true, Width: width}
|
||||
}
|
||||
|
||||
func txt(key, label, placeholder, help string, width int) Field {
|
||||
return Field{Key: key, Label: label, Type: "text", Placeholder: placeholder,
|
||||
Help: help, Required: true, Width: width}
|
||||
}
|
||||
|
||||
// recordTypes drives both validation and the UI editors.
|
||||
var recordTypes = []TypeInfo{
|
||||
{Type: "A", Description: "IPv4 address", Common: true, Fields: []Field{
|
||||
txt("address", "IPv4 address", "192.0.2.10", "A single IPv4 address.", 12),
|
||||
}},
|
||||
{Type: "AAAA", Description: "IPv6 address", Common: true, Fields: []Field{
|
||||
txt("address", "IPv6 address", "2001:db8::10", "A single IPv6 address.", 12),
|
||||
}},
|
||||
{Type: "CNAME", Description: "Canonical name alias", Common: true, Fields: []Field{
|
||||
txt("target", "Target", "example.com.", "The name this record is an alias for. End with a dot for an absolute name.", 12),
|
||||
}},
|
||||
{Type: "MX", Description: "Mail exchanger", Common: true, Fields: []Field{
|
||||
num("preference", "Preference", "10", "Lower values are preferred.", 3),
|
||||
txt("exchange", "Mail server", "mail.example.com.", "Host name of the mail server. Must not be a CNAME.", 9),
|
||||
}},
|
||||
{Type: "TXT", Description: "Free-form text", Common: true, Fields: []Field{
|
||||
{Key: "text", Label: "Text", Type: "textarea", Required: true, Quote: true, Width: 12,
|
||||
Placeholder: "v=spf1 include:_spf.example.com ~all",
|
||||
Help: "Quoting and 255-character chunking are handled automatically."},
|
||||
}},
|
||||
{Type: "NS", Description: "Name server delegation", Common: true, Fields: []Field{
|
||||
txt("nameserver", "Name server", "ns1.example.com.", "Authoritative name server for this name.", 12),
|
||||
}},
|
||||
{Type: "SRV", Description: "Service location", Common: true, Fields: []Field{
|
||||
num("priority", "Priority", "10", "Lower values are preferred.", 3),
|
||||
num("weight", "Weight", "20", "Relative weight among equal priorities.", 3),
|
||||
num("port", "Port", "5060", "TCP or UDP port of the service.", 3),
|
||||
txt("target", "Target", "sip.example.com.", "Host providing the service.", 3),
|
||||
}},
|
||||
{Type: "PTR", Description: "Reverse pointer", Common: true, Fields: []Field{
|
||||
txt("target", "Points to", "host.example.com.", "The host name this address belongs to.", 12),
|
||||
}},
|
||||
{Type: "CAA", Description: "Certificate authority authorisation", Common: true, Fields: []Field{
|
||||
num("flags", "Flags", "0", "128 marks the property as critical.", 2),
|
||||
{Key: "tag", Label: "Tag", Type: "select", Required: true, Width: 3,
|
||||
Options: []string{"issue", "issuewild", "iodef", "contactemail", "contactphone"}},
|
||||
{Key: "value", Label: "Value", Type: "text", Required: true, Quote: true, Width: 7,
|
||||
Placeholder: "letsencrypt.org", Help: "The CA domain, or a mailto:/https: URL for iodef."},
|
||||
}},
|
||||
{Type: "SOA", Description: "Start of authority", Fields: []Field{
|
||||
txt("ns", "Primary name server", "ns1.example.com.", "", 6),
|
||||
txt("mbox", "Responsible party", "hostmaster.example.com.", "The @ in the email address becomes a dot.", 6),
|
||||
num("serial", "Serial", "1", "", 4),
|
||||
num("refresh", "Refresh", "7200", "", 4),
|
||||
num("retry", "Retry", "3600", "", 4),
|
||||
num("expire", "Expire", "1209600", "", 6),
|
||||
num("minimum", "Minimum / negative TTL", "3600", "", 6),
|
||||
}},
|
||||
{Type: "NAPTR", Description: "Naming authority pointer", Fields: []Field{
|
||||
num("order", "Order", "100", "", 3),
|
||||
num("preference", "Preference", "10", "", 3),
|
||||
{Key: "flags", Label: "Flags", Type: "text", Quote: true, Width: 3, Placeholder: "U"},
|
||||
{Key: "service", Label: "Service", Type: "text", Quote: true, Width: 3, Placeholder: "E2U+sip"},
|
||||
{Key: "regexp", Label: "Regexp", Type: "text", Quote: true, Width: 8,
|
||||
Placeholder: `!^.*$!sip:info@example.com!`},
|
||||
txt("replacement", "Replacement", ".", "Use a single dot when a regexp is given.", 4),
|
||||
}},
|
||||
{Type: "TLSA", Description: "TLS certificate association", Fields: []Field{
|
||||
num("usage", "Usage", "3", "0-3; 3 is a domain-issued certificate.", 3),
|
||||
num("selector", "Selector", "1", "0 full certificate, 1 public key.", 3),
|
||||
num("matching_type", "Matching type", "1", "0 exact, 1 SHA-256, 2 SHA-512.", 3),
|
||||
txt("certificate", "Certificate data", "abc123...", "Hex encoded association data.", 3),
|
||||
}},
|
||||
{Type: "SSHFP", Description: "SSH host key fingerprint", Fields: []Field{
|
||||
num("algorithm", "Algorithm", "4", "1 RSA, 2 DSA, 3 ECDSA, 4 Ed25519.", 4),
|
||||
num("type", "Fingerprint type", "2", "1 SHA-1, 2 SHA-256.", 4),
|
||||
txt("fingerprint", "Fingerprint", "abc123...", "Hex encoded fingerprint.", 4),
|
||||
}},
|
||||
{Type: "SVCB", Description: "Service binding", Fields: []Field{
|
||||
num("priority", "Priority", "1", "0 selects alias mode.", 3),
|
||||
txt("target", "Target", "svc.example.com.", "", 4),
|
||||
{Key: "params", Label: "Parameters", Type: "text", Width: 5,
|
||||
Placeholder: "alpn=h2,h3 port=8443", Help: "Space separated key=value pairs."},
|
||||
}},
|
||||
{Type: "HTTPS", Description: "HTTPS service binding", Common: true, Fields: []Field{
|
||||
num("priority", "Priority", "1", "0 selects alias mode.", 3),
|
||||
txt("target", "Target", ".", "A single dot means the owner name itself.", 4),
|
||||
{Key: "params", Label: "Parameters", Type: "text", Width: 5,
|
||||
Placeholder: "alpn=h2,h3 ipv4hint=192.0.2.10", Help: "Space separated key=value pairs."},
|
||||
}},
|
||||
{Type: "DS", Description: "Delegation signer", Fields: []Field{
|
||||
num("key_tag", "Key tag", "12345", "", 3),
|
||||
num("algorithm", "Algorithm", "13", "8 RSASHA256, 13 ECDSAP256SHA256, 15 ED25519.", 3),
|
||||
num("digest_type", "Digest type", "2", "1 SHA-1, 2 SHA-256, 4 SHA-384.", 3),
|
||||
txt("digest", "Digest", "abc123...", "Hex encoded digest.", 3),
|
||||
}},
|
||||
{Type: "DNSKEY", Description: "DNSSEC public key", Fields: []Field{
|
||||
num("flags", "Flags", "257", "256 zone signing key, 257 key signing key.", 3),
|
||||
num("protocol", "Protocol", "3", "Always 3.", 3),
|
||||
num("algorithm", "Algorithm", "13", "8 RSASHA256, 13 ECDSAP256SHA256, 15 ED25519.", 3),
|
||||
{Key: "public_key", Label: "Public key", Type: "textarea", Required: true, Width: 12,
|
||||
Placeholder: "base64 encoded key material"},
|
||||
}},
|
||||
{Type: "DNAME", Description: "Delegation name redirection", Fields: []Field{
|
||||
txt("target", "Target", "example.net.", "Rewrites the entire subtree below this name.", 12),
|
||||
}},
|
||||
{Type: "SPF", Description: "Legacy sender policy (prefer TXT)", Fields: []Field{
|
||||
{Key: "text", Label: "Policy", Type: "textarea", Required: true, Quote: true, Width: 12,
|
||||
Placeholder: "v=spf1 mx ~all"},
|
||||
}},
|
||||
{Type: "LOC", Description: "Geographic location", Fields: []Field{
|
||||
{Key: "raw", Label: "Location", Type: "text", Required: true, Width: 12,
|
||||
Placeholder: "51 30 12.748 N 0 7 39.611 W 0.00m"},
|
||||
}},
|
||||
{Type: "RAW", Description: "Advanced: any record type, entered by hand", Fields: []Field{
|
||||
{Key: "rtype", Label: "Record type", Type: "text", Required: true, Width: 4,
|
||||
Placeholder: "URI", Help: "Any type name known to the DNS library, or TYPE65534 for unknown types."},
|
||||
{Key: "rdata", Label: "Record data", Type: "textarea", Required: true, Width: 8,
|
||||
Placeholder: `10 1 "https://example.com/"`,
|
||||
Help: "Rdata exactly as it would appear in a zone file. Unknown types use the RFC 3597 form: \\# 4 0A0B0C0D"},
|
||||
}},
|
||||
}
|
||||
|
||||
// TypeInfos returns the record type catalogue used by the UI.
|
||||
func TypeInfos() []TypeInfo { return recordTypes }
|
||||
|
||||
// TypeInfoFor looks up one record type's editor definition.
|
||||
func TypeInfoFor(t string) (TypeInfo, bool) {
|
||||
t = strings.ToUpper(strings.TrimSpace(t))
|
||||
for _, ti := range recordTypes {
|
||||
if ti.Type == t {
|
||||
return ti, true
|
||||
}
|
||||
}
|
||||
return TypeInfo{}, false
|
||||
}
|
||||
|
||||
// KnownType reports whether the DNS library understands a type name. This
|
||||
// accepts far more types than have dedicated editors, including the RFC 3597
|
||||
// TYPEnnnnn form.
|
||||
func KnownType(t string) bool {
|
||||
t = strings.ToUpper(strings.TrimSpace(t))
|
||||
if _, ok := dns.StringToType[t]; ok {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(t, "TYPE") {
|
||||
if n, err := strconv.Atoi(t[4:]); err == nil && n > 0 && n <= 65535 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormaliseType uppercases and checks a record type name.
|
||||
func NormaliseType(t string) (string, error) {
|
||||
t = strings.ToUpper(strings.TrimSpace(t))
|
||||
if t == "" {
|
||||
return "", errors.New("record type must not be empty")
|
||||
}
|
||||
if t == "RAW" {
|
||||
return "", errors.New("choose a concrete record type in the advanced editor")
|
||||
}
|
||||
if !KnownType(t) {
|
||||
return "", fmt.Errorf("%q is not a known DNS record type; "+
|
||||
"use the advanced editor with the TYPEnnnnn form for unassigned types", t)
|
||||
}
|
||||
switch t {
|
||||
case "ANY", "AXFR", "IXFR", "OPT", "TSIG", "TKEY":
|
||||
return "", fmt.Errorf("%s is a meta record type and cannot be stored in a zone", t)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// BuildRR assembles and validates a resource record.
|
||||
//
|
||||
// The zone origin is handed to the parser so that relative names in rdata (and
|
||||
// "@") resolve exactly the way they would in a real zone file.
|
||||
func BuildRR(zone, name, rtype, data string, ttl uint32) (dns.RR, error) {
|
||||
rtype, err := NormaliseType(rtype)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = strings.TrimSpace(data)
|
||||
if data == "" {
|
||||
return nil, fmt.Errorf("%s record data must not be empty", rtype)
|
||||
}
|
||||
if err := preflight(rtype, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
owner := name
|
||||
if owner == "" {
|
||||
owner = "@"
|
||||
}
|
||||
line := fmt.Sprintf("%s %d IN %s %s", owner, ttl, rtype, data)
|
||||
|
||||
zp := dns.NewZoneParser(strings.NewReader(line), zone, "record")
|
||||
zp.SetDefaultTTL(ttl)
|
||||
rr, ok := zp.Next()
|
||||
if err := zp.Err(); err != nil {
|
||||
return nil, fmt.Errorf("invalid %s record data: %s", rtype, cleanParseError(err))
|
||||
}
|
||||
if !ok || rr == nil {
|
||||
return nil, fmt.Errorf("invalid %s record data: %q could not be parsed", rtype, data)
|
||||
}
|
||||
if _, more := zp.Next(); more {
|
||||
return nil, fmt.Errorf("%s record data must be a single record", rtype)
|
||||
}
|
||||
return rr, nil
|
||||
}
|
||||
|
||||
// preflight catches the mistakes users actually make, so they get a sentence
|
||||
// they can act on instead of a parser offset.
|
||||
func preflight(rtype, data string) error {
|
||||
switch rtype {
|
||||
case "A":
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(data))
|
||||
if err != nil || !addr.Is4() {
|
||||
return fmt.Errorf("an A record needs a valid IPv4 address, for example 192.0.2.10 (got %q)", data)
|
||||
}
|
||||
case "AAAA":
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(data))
|
||||
if err != nil || addr.Is4() {
|
||||
return fmt.Errorf("an AAAA record needs a valid IPv6 address, for example 2001:db8::10 (got %q)", data)
|
||||
}
|
||||
case "MX":
|
||||
f := strings.Fields(data)
|
||||
if len(f) != 2 {
|
||||
return fmt.Errorf("an MX record needs a preference and a host name, for example: 10 mail.example.com.")
|
||||
}
|
||||
if _, err := strconv.ParseUint(f[0], 10, 16); err != nil {
|
||||
return fmt.Errorf("the MX preference %q must be a number between 0 and 65535", f[0])
|
||||
}
|
||||
case "SRV":
|
||||
f := strings.Fields(data)
|
||||
if len(f) != 4 {
|
||||
return fmt.Errorf("an SRV record needs priority, weight, port and target, " +
|
||||
"for example: 10 20 5060 sip.example.com.")
|
||||
}
|
||||
for i, label := range []string{"priority", "weight", "port"} {
|
||||
if _, err := strconv.ParseUint(f[i], 10, 16); err != nil {
|
||||
return fmt.Errorf("the SRV %s %q must be a number between 0 and 65535", label, f[i])
|
||||
}
|
||||
}
|
||||
case "CAA":
|
||||
f := strings.Fields(data)
|
||||
if len(f) < 3 {
|
||||
return fmt.Errorf(`a CAA record needs flags, a tag and a quoted value, ` +
|
||||
`for example: 0 issue "letsencrypt.org"`)
|
||||
}
|
||||
case "CNAME", "PTR", "NS", "DNAME":
|
||||
if strings.Fields(data) == nil || len(strings.Fields(data)) != 1 {
|
||||
return fmt.Errorf("a %s record takes exactly one name, for example: host.example.com.", rtype)
|
||||
}
|
||||
case "TXT", "SPF":
|
||||
if !strings.HasPrefix(strings.TrimSpace(data), `"`) {
|
||||
return fmt.Errorf("%s record data must be quoted; the record editor does this for you", rtype)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanParseError strips the parser's file/line noise, which is meaningless
|
||||
// for a single record typed into a form.
|
||||
func cleanParseError(err error) string {
|
||||
msg := err.Error()
|
||||
if i := strings.Index(msg, "dns: "); i >= 0 {
|
||||
msg = msg[i+len("dns: "):]
|
||||
}
|
||||
if i := strings.Index(msg, " at record:"); i >= 0 {
|
||||
msg = msg[:i]
|
||||
}
|
||||
if i := strings.Index(msg, "\" at line"); i >= 0 {
|
||||
msg = msg[:i+1]
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// QuoteTXT turns free text into one or more quoted character-strings, splitting
|
||||
// at the 255 byte limit that a single DNS character-string may hold.
|
||||
func QuoteTXT(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return `""`
|
||||
}
|
||||
// Already-quoted input is passed through so operators can hand-craft
|
||||
// multi-string records.
|
||||
if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) && len(s) > 1 {
|
||||
return s
|
||||
}
|
||||
const maxChunk = 255
|
||||
var chunks []string
|
||||
for len(s) > maxChunk {
|
||||
chunks = append(chunks, s[:maxChunk])
|
||||
s = s[maxChunk:]
|
||||
}
|
||||
chunks = append(chunks, s)
|
||||
for i, c := range chunks {
|
||||
chunks[i] = `"` + escapeCharString(c) + `"`
|
||||
}
|
||||
return strings.Join(chunks, " ")
|
||||
}
|
||||
|
||||
func escapeCharString(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 8)
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"', '\\':
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AssembleRData joins editor field values into zone-file rdata, applying
|
||||
// quoting where the record type requires it.
|
||||
func AssembleRData(rtype string, values map[string]string) (string, error) {
|
||||
info, ok := TypeInfoFor(rtype)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no editor is defined for record type %q", rtype)
|
||||
}
|
||||
var parts []string
|
||||
for _, f := range info.Fields {
|
||||
v := strings.TrimSpace(values[f.Key])
|
||||
if v == "" {
|
||||
if f.Required {
|
||||
return "", fmt.Errorf("%s is required for a %s record", f.Label, rtype)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.Quote {
|
||||
v = QuoteTXT(v)
|
||||
}
|
||||
parts = append(parts, v)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("%s record data must not be empty", rtype)
|
||||
}
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
|
||||
// SplitRData splits stored rdata back into editor field values so an existing
|
||||
// record can be edited in its dedicated form.
|
||||
func SplitRData(rtype, data string) map[string]string {
|
||||
out := map[string]string{}
|
||||
info, ok := TypeInfoFor(rtype)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
// Types whose final field swallows the remainder of the line.
|
||||
fields := info.Fields
|
||||
if len(fields) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// A single quoted field (TXT, SPF) takes the whole rdata verbatim.
|
||||
if len(fields) == 1 {
|
||||
v := data
|
||||
if fields[0].Quote {
|
||||
v = UnquoteTXT(data)
|
||||
}
|
||||
out[fields[0].Key] = v
|
||||
return out
|
||||
}
|
||||
|
||||
toks := tokeniseRData(data)
|
||||
for i, f := range fields {
|
||||
if i >= len(toks) {
|
||||
break
|
||||
}
|
||||
if i == len(fields)-1 && len(toks) > len(fields) {
|
||||
// Trailing field absorbs everything left (e.g. SVCB parameters).
|
||||
rest := strings.Join(toks[i:], " ")
|
||||
if f.Quote {
|
||||
rest = UnquoteTXT(rest)
|
||||
}
|
||||
out[f.Key] = rest
|
||||
break
|
||||
}
|
||||
v := toks[i]
|
||||
if f.Quote {
|
||||
v = UnquoteTXT(v)
|
||||
}
|
||||
out[f.Key] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tokeniseRData splits on whitespace while keeping quoted strings together.
|
||||
func tokeniseRData(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inQuote, escaped, started := false, false, false
|
||||
flush := func() {
|
||||
if started {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
started = false
|
||||
}
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case escaped:
|
||||
cur.WriteRune(r)
|
||||
escaped = false
|
||||
started = true
|
||||
case r == '\\':
|
||||
cur.WriteRune(r)
|
||||
escaped = true
|
||||
started = true
|
||||
case r == '"':
|
||||
cur.WriteRune(r)
|
||||
inQuote = !inQuote
|
||||
started = true
|
||||
case (r == ' ' || r == '\t') && !inQuote:
|
||||
flush()
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
started = true
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// UnquoteTXT reverses QuoteTXT, concatenating adjacent character-strings.
|
||||
func UnquoteTXT(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.Contains(s, `"`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
inQuote, escaped := false, false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case escaped:
|
||||
b.WriteRune(r)
|
||||
escaped = false
|
||||
case r == '\\' && inQuote:
|
||||
escaped = true
|
||||
case r == '"':
|
||||
inQuote = !inQuote
|
||||
case inQuote:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// CNAMEConflict reports whether adding a record of type newType at a name that
|
||||
// already holds the given types would create an illegal combination.
|
||||
//
|
||||
// RFC 1034 forbids a CNAME from coexisting with any other data at the same
|
||||
// name, with the DNSSEC types being the standard exception.
|
||||
func CNAMEConflict(newType string, existing []string) error {
|
||||
newType = strings.ToUpper(newType)
|
||||
hasCNAME := false
|
||||
otherTypes := 0
|
||||
for _, t := range existing {
|
||||
t = strings.ToUpper(t)
|
||||
if t == "CNAME" {
|
||||
hasCNAME = true
|
||||
continue
|
||||
}
|
||||
if !dnssecCompatible(t) {
|
||||
otherTypes++
|
||||
}
|
||||
}
|
||||
if newType == "CNAME" {
|
||||
if hasCNAME {
|
||||
return errors.New("this name already has a CNAME record; a name may only have one")
|
||||
}
|
||||
if otherTypes > 0 {
|
||||
return errors.New("a CNAME cannot coexist with other records at the same name; " +
|
||||
"remove the existing records or use a different name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hasCNAME && !dnssecCompatible(newType) {
|
||||
return fmt.Errorf("this name already has a CNAME record, so it cannot also have a %s record", newType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dnssecCompatible reports whether a type is permitted alongside a CNAME.
|
||||
func dnssecCompatible(t string) bool {
|
||||
switch strings.ToUpper(t) {
|
||||
case "RRSIG", "NSEC", "NSEC3", "KEY", "DS":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApexRestricted reports whether a record type is illegal at a zone apex.
|
||||
func ApexRestricted(rtype string) error {
|
||||
switch strings.ToUpper(rtype) {
|
||||
case "CNAME":
|
||||
return errors.New("a CNAME cannot be placed at the zone apex; " +
|
||||
"use an A, AAAA or HTTPS record instead")
|
||||
case "DNAME":
|
||||
return errors.New("a DNAME cannot be placed at the zone apex")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormaliseFQDN(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"example.com", "example.com.", false},
|
||||
{"example.com.", "example.com.", false},
|
||||
{"EXAMPLE.COM", "example.com.", false},
|
||||
{" example.com ", "example.com.", false},
|
||||
{"a.b.c.example.com", "a.b.c.example.com.", false},
|
||||
{"_dmarc.example.com", "_dmarc.example.com.", false}, // underscores are needed by SRV/DKIM
|
||||
{".", ".", false},
|
||||
{"", "", true},
|
||||
{"example..com", "", true},
|
||||
{"-bad.example.com", "", true},
|
||||
{"bad-.example.com", "", true},
|
||||
{"exa mple.com", "", true},
|
||||
{strings.Repeat("a", 64) + ".example.com", "", true}, // label too long
|
||||
{strings.Repeat("a.", 130) + "example.com", "", true}, // name too long
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got, err := NormaliseFQDN(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected an error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseRecordName(t *testing.T) {
|
||||
const zone = "example.com."
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", "@", false},
|
||||
{"@", "@", false},
|
||||
{"www", "www", false},
|
||||
{"WWW", "www", false},
|
||||
{"www.example.com.", "www", false}, // absolute inside the zone
|
||||
{"example.com.", "@", false}, // the apex itself
|
||||
{"*", "*", false}, // wildcard
|
||||
{"*.sub", "*.sub", false},
|
||||
{"a.b.c", "a.b.c", false},
|
||||
{"www.example.org.", "", true}, // outside the zone
|
||||
{"sub.*", "", true}, // wildcard must be leftmost
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got, err := NormaliseRecordName(tc.in, zone)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected an error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReverseZone covers the feature that spares the operator from reversing
|
||||
// octets by hand.
|
||||
func TestReverseZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
cidr string
|
||||
want string
|
||||
wantNote bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"192.168.1.0/24", "1.168.192.in-addr.arpa.", false, false},
|
||||
{"10.0.0.0/8", "10.in-addr.arpa.", false, false},
|
||||
{"172.16.0.0/16", "16.172.in-addr.arpa.", false, false},
|
||||
{"192.0.2.10", "10.2.0.192.in-addr.arpa.", false, false}, // bare host
|
||||
// Non-octet boundaries round down to the enclosing zone with a note.
|
||||
{"192.168.1.0/25", "1.168.192.in-addr.arpa.", true, false},
|
||||
{"10.1.2.3/30", "2.1.10.in-addr.arpa.", true, false},
|
||||
|
||||
{"2001:db8::/32", "8.b.d.0.1.0.0.2.ip6.arpa.", false, false},
|
||||
{"2001:db8:1::/48", "1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", false, false},
|
||||
{"2001:db8::/33", "8.b.d.0.1.0.0.2.ip6.arpa.", true, false},
|
||||
|
||||
{"not-a-cidr", "", false, true},
|
||||
{"192.168.1.0/4", "", false, true}, // finer than a /8 leaves no IPv4 zone
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.cidr, func(t *testing.T) {
|
||||
zone, note, err := ReverseZone(tc.cidr)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected an error, got %q", zone)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if zone != tc.want {
|
||||
t.Errorf("zone = %q, want %q", zone, tc.want)
|
||||
}
|
||||
if tc.wantNote && note == "" {
|
||||
t.Error("expected a note explaining the rounding")
|
||||
}
|
||||
if !tc.wantNote && note != "" {
|
||||
t.Errorf("unexpected note: %s", note)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPTRName(t *testing.T) {
|
||||
tests := []struct{ ip, want string }{
|
||||
{"192.0.2.10", "10.2.0.192.in-addr.arpa."},
|
||||
{"10.1.2.3", "3.2.1.10.in-addr.arpa."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := PTRName(tc.ip)
|
||||
if err != nil {
|
||||
t.Fatalf("PTRName(%q): %v", tc.ip, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("PTRName(%q) = %q, want %q", tc.ip, got, tc.want)
|
||||
}
|
||||
}
|
||||
if _, err := PTRName("not-an-ip"); err == nil {
|
||||
t.Error("expected an error for an invalid address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRRValidation(t *testing.T) {
|
||||
const zone = "example.com."
|
||||
tests := []struct {
|
||||
name string
|
||||
rtype string
|
||||
data string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid A", "A", "192.0.2.1", false},
|
||||
{"A with IPv6", "A", "2001:db8::1", true},
|
||||
{"A with garbage", "A", "not-an-ip", true},
|
||||
{"valid AAAA", "AAAA", "2001:db8::1", false},
|
||||
{"AAAA with IPv4", "AAAA", "192.0.2.1", true},
|
||||
{"valid CNAME", "CNAME", "target.example.com.", false},
|
||||
{"CNAME with two names", "CNAME", "a.example.com. b.example.com.", true},
|
||||
{"valid MX", "MX", "10 mail.example.com.", false},
|
||||
{"MX without preference", "MX", "mail.example.com.", true},
|
||||
{"MX with bad preference", "MX", "abc mail.example.com.", true},
|
||||
{"valid TXT", "TXT", `"some text"`, false},
|
||||
{"unquoted TXT", "TXT", "some text", true},
|
||||
{"valid SRV", "SRV", "10 20 5060 sip.example.com.", false},
|
||||
{"SRV missing fields", "SRV", "10 20 sip.example.com.", true},
|
||||
{"valid CAA", "CAA", `0 issue "letsencrypt.org"`, false},
|
||||
{"valid TLSA", "TLSA", "3 1 1 abcdef0123456789", false},
|
||||
{"valid SSHFP", "SSHFP", "4 2 abcdef0123456789", false},
|
||||
{"valid DS", "DS", "12345 13 2 abcdef0123456789", false},
|
||||
{"valid HTTPS", "HTTPS", "1 . alpn=h2,h3", false},
|
||||
{"valid NS", "NS", "ns1.example.com.", false},
|
||||
{"valid PTR", "PTR", "host.example.com.", false},
|
||||
{"unknown type", "NOTATYPE", "whatever", true},
|
||||
{"meta type rejected", "ANY", "whatever", true},
|
||||
{"RFC3597 unknown type", "TYPE65280", `\# 4 0A0B0C0D`, false},
|
||||
{"empty data", "A", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := BuildRR(zone, "test", tc.rtype, tc.data, 3600)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Error("expected an error, got none")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCNAMEConflict covers the RFC 1034 rule that trips people up most often.
|
||||
func TestCNAMEConflict(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
newType string
|
||||
existing []string
|
||||
wantErr bool
|
||||
}{
|
||||
{"CNAME on an empty name", "CNAME", nil, false},
|
||||
{"second CNAME", "CNAME", []string{"CNAME"}, true},
|
||||
{"CNAME alongside an A", "CNAME", []string{"A"}, true},
|
||||
{"A alongside a CNAME", "A", []string{"CNAME"}, true},
|
||||
{"MX alongside a CNAME", "MX", []string{"CNAME"}, true},
|
||||
{"A alongside another A", "A", []string{"A"}, false},
|
||||
{"A alongside AAAA", "A", []string{"AAAA"}, false},
|
||||
// DNSSEC types are the standard exception.
|
||||
{"RRSIG alongside a CNAME", "RRSIG", []string{"CNAME"}, false},
|
||||
{"CNAME alongside RRSIG only", "CNAME", []string{"RRSIG", "NSEC"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := CNAMEConflict(tc.newType, tc.existing)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Error("expected a conflict, got none")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("unexpected conflict: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApexRestrictions(t *testing.T) {
|
||||
if err := ApexRestricted("CNAME"); err == nil {
|
||||
t.Error("a CNAME at the apex must be rejected")
|
||||
}
|
||||
if err := ApexRestricted("DNAME"); err == nil {
|
||||
t.Error("a DNAME at the apex must be rejected")
|
||||
}
|
||||
for _, ok := range []string{"A", "AAAA", "MX", "TXT", "NS", "HTTPS"} {
|
||||
if err := ApexRestricted(ok); err != nil {
|
||||
t.Errorf("%s should be allowed at the apex: %v", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteTXT(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"hello", `"hello"`},
|
||||
{"", `""`},
|
||||
{`already "quoted"`, `"already \"quoted\""`},
|
||||
{`"pre-quoted"`, `"pre-quoted"`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := QuoteTXT(tc.in); got != tc.want {
|
||||
t.Errorf("QuoteTXT(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A string past the 255-byte character-string limit must be split into
|
||||
// several quoted chunks, not truncated.
|
||||
long := strings.Repeat("a", 600)
|
||||
got := QuoteTXT(long)
|
||||
if strings.Count(got, `"`) != 6 {
|
||||
t.Errorf("a 600 character string produced %d quotes, want 6 (three chunks)", strings.Count(got, `"`))
|
||||
}
|
||||
if _, err := BuildRR("example.com.", "long", "TXT", got, 300); err != nil {
|
||||
t.Errorf("chunked TXT did not parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleAndSplitRData(t *testing.T) {
|
||||
tests := []struct {
|
||||
rtype string
|
||||
fields map[string]string
|
||||
want string
|
||||
}{
|
||||
{"A", map[string]string{"address": "192.0.2.1"}, "192.0.2.1"},
|
||||
{"MX", map[string]string{"preference": "10", "exchange": "mail.example.com."}, "10 mail.example.com."},
|
||||
{"SRV", map[string]string{"priority": "10", "weight": "20", "port": "5060", "target": "sip.example.com."},
|
||||
"10 20 5060 sip.example.com."},
|
||||
{"TXT", map[string]string{"text": "v=spf1 -all"}, `"v=spf1 -all"`},
|
||||
{"CAA", map[string]string{"flags": "0", "tag": "issue", "value": "letsencrypt.org"},
|
||||
`0 issue "letsencrypt.org"`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.rtype, func(t *testing.T) {
|
||||
got, err := AssembleRData(tc.rtype, tc.fields)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("assembled = %q, want %q", got, tc.want)
|
||||
}
|
||||
if _, err := BuildRR("example.com.", "test", tc.rtype, got, 3600); err != nil {
|
||||
t.Errorf("assembled rdata does not parse: %v", err)
|
||||
}
|
||||
|
||||
// Round trip: splitting must return the values we started with.
|
||||
split := SplitRData(tc.rtype, got)
|
||||
for k, v := range tc.fields {
|
||||
if split[k] != v {
|
||||
t.Errorf("round trip field %q = %q, want %q", k, split[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleRequiresMandatoryFields(t *testing.T) {
|
||||
if _, err := AssembleRData("MX", map[string]string{"exchange": "mail.example.com."}); err == nil {
|
||||
t.Error("expected an error when a required field is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeCatalogue(t *testing.T) {
|
||||
// Every type the brief asks for must have a dedicated editor.
|
||||
required := []string{
|
||||
"A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "PTR", "CAA", "SOA",
|
||||
"NAPTR", "TLSA", "SSHFP", "SVCB", "HTTPS", "DS", "DNSKEY",
|
||||
}
|
||||
for _, want := range required {
|
||||
info, ok := TypeInfoFor(want)
|
||||
if !ok {
|
||||
t.Errorf("no editor is defined for %s", want)
|
||||
continue
|
||||
}
|
||||
if len(info.Fields) == 0 {
|
||||
t.Errorf("%s has an editor with no fields", want)
|
||||
}
|
||||
}
|
||||
if _, ok := TypeInfoFor("RAW"); !ok {
|
||||
t.Error("the advanced raw editor is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSubdomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
child, parent string
|
||||
want bool
|
||||
}{
|
||||
{"www.example.com.", "example.com.", true},
|
||||
{"example.com.", "example.com.", true},
|
||||
{"a.b.example.com.", "example.com.", true},
|
||||
{"notexample.com.", "example.com.", false},
|
||||
{"example.org.", "example.com.", false},
|
||||
{"anything.", ".", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := IsSubdomain(tc.child, tc.parent); got != tc.want {
|
||||
t.Errorf("IsSubdomain(%q, %q) = %v, want %v", tc.child, tc.parent, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user