initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+272
View File
@@ -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"
}
}
+541
View File
@@ -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
}
+357
View File
@@ -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)
}
}
}