Files
vibedns/internal/validate/dnsname.go
T
2026-08-16 21:18:45 -05:00

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"
}
}