273 lines
8.0 KiB
Go
273 lines
8.0 KiB
Go
// 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"
|
|
}
|
|
}
|