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