Files
adamandClaude Opus 5.5 aa4b004a14 Reverse zones made easy: suggested, filled, in the right view, PTRs kept in step
Missing reverse zones are suggested for the private views' A and AAAA records (/24 for private IPv4, /32 for a public address, /48 for IPv6) and created filled with PTR records (jarl zone suggest-reverse, the Zones page). zone create --fill and zone fill-ptr add the PTRs a reverse zone lacks; a reverse zone created without a view goes to the private view using its addresses. Per zone, auto_ptr keeps the PTR records of A and AAAA records in step on every change (database version 12). Zone commands take a network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:23:59 +01:00

516 lines
15 KiB
Go

// Package dnsname contains helpers for handling DNS names the way Jarl stores
// them: lowercase, fully qualified, with a trailing dot ("www.example.com.").
package dnsname
import (
"errors"
"fmt"
"net/netip"
"strconv"
"strings"
"github.com/miekg/dns"
"golang.org/x/net/idna"
)
// Root is the root domain name.
const Root = "."
// Fqdn lowercases name and makes sure it ends with a dot. An empty name
// becomes the root name.
func Fqdn(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" || name == "." {
return Root
}
if !strings.HasSuffix(name, ".") || strings.HasSuffix(name, `\.`) && !strings.HasSuffix(name, `\\.`) {
name += "."
}
return name
}
// Display returns name without its trailing dot, which is how names are
// shown to people. The root name is shown as ".".
func Display(name string) string {
if name == Root || name == "" {
return Root
}
return strings.TrimSuffix(name, ".")
}
// IsSubdomain reports whether child is equal to, or below, parent. Both names
// must be canonical (see Fqdn).
func IsSubdomain(child, parent string) bool {
if parent == Root {
return true
}
return child == parent || strings.HasSuffix(child, "."+parent)
}
// IsStrictSubdomain reports whether child is below parent (and not equal).
func IsStrictSubdomain(child, parent string) bool {
return child != parent && IsSubdomain(child, parent)
}
// Parent returns the name with its leftmost label removed. The parent of a
// top-level name is the root, and the root has no parent ("").
func Parent(name string) string {
if name == Root {
return ""
}
i := strings.IndexByte(name, '.')
if i < 0 || i == len(name)-1 {
return Root
}
return name[i+1:]
}
// FirstLabel returns the leftmost label of name.
func FirstLabel(name string) string {
if name == Root {
return ""
}
if i := strings.IndexByte(name, '.'); i >= 0 {
return name[:i]
}
return name
}
// CountLabels returns the number of labels in name (the root has 0).
func CountLabels(name string) int {
if name == Root {
return 0
}
return strings.Count(strings.TrimSuffix(name, "."), ".") + 1
}
// ToASCII converts an internationalized name to its ASCII (punycode) form.
// Names that are already ASCII are returned lowercased and unchanged.
func ToASCII(name string) (string, error) {
for i := 0; i < len(name); i++ {
if name[i] >= 0x80 {
trailing := strings.HasSuffix(name, ".")
a, err := idna.Lookup.ToASCII(strings.TrimSuffix(name, "."))
if err != nil {
return "", fmt.Errorf("invalid internationalized name %q: %v", name, err)
}
if trailing {
a += "."
}
return strings.ToLower(a), nil
}
}
return strings.ToLower(name), nil
}
// Owner resolves a user supplied owner name against zone (canonical FQDN)
// and returns the canonical FQDN.
//
// - "" and "@" mean the zone apex.
// - A name ending in "." is absolute.
// - A name without a trailing dot that equals the zone name, or ends with
// ".<zone>", is treated as absolute. Nobody wants
// "www.example.com.example.com.", and this is how most DNS APIs behave.
// - Anything else is relative to the zone.
func Owner(input, zone string) (string, error) {
in := strings.TrimSpace(input)
if in == "" || in == "@" {
return zone, nil
}
// In reverse zones an address stands for its PTR name.
if IsReverse(zone) {
if addr, err := netip.ParseAddr(strings.TrimSuffix(in, ".")); err == nil {
return ReverseName(addr), nil
}
}
a, err := ToASCII(in)
if err != nil {
return "", err
}
in = a
if strings.HasSuffix(in, ".") {
return Fqdn(in), nil
}
zoneNoDot := strings.TrimSuffix(zone, ".")
if zone != Root && (in == zoneNoDot || strings.HasSuffix(in, "."+zoneNoDot)) {
return Fqdn(in), nil
}
if zone == Root {
return Fqdn(in), nil
}
return Fqdn(in + "." + zone), nil
}
// Relative returns name relative to zone: "@" for the apex, the remaining
// labels for names inside the zone, and the FQDN for names outside it.
func Relative(name, zone string) string {
if name == zone {
return "@"
}
if zone == Root {
return name
}
if strings.HasSuffix(name, "."+zone) {
return strings.TrimSuffix(name, "."+zone)
}
return name
}
// Syntax errors returned by CheckOwner and CheckZoneName.
var (
ErrEmptyLabel = errors.New("empty label (two dots in a row?)")
ErrTooLong = errors.New("name is longer than 255 octets")
)
// CheckOwner validates the syntax of a canonical owner name. Labels may
// contain letters, digits, hyphens, underscores and slashes (RFC 2317 style
// reverse names); a "*" label is allowed only as the leftmost label.
func CheckOwner(name string) error {
return checkName(name, true)
}
// CheckZoneName validates the syntax of a canonical zone name. It is the same
// as CheckOwner except that wildcards are not allowed.
func CheckZoneName(name string) error {
return checkName(name, false)
}
func checkName(name string, wildcardOK bool) error {
if name == Root {
return nil
}
if !strings.HasSuffix(name, ".") {
return fmt.Errorf("name %q is not fully qualified", name)
}
// Wire length: one length octet per label plus the label bytes, plus
// the terminating zero octet. For a dotted name that's len(name)+1.
if len(name)+1 > 255 {
return ErrTooLong
}
labels := strings.Split(strings.TrimSuffix(name, "."), ".")
for i, l := range labels {
if l == "" {
return ErrEmptyLabel
}
if len(l) > 63 {
return fmt.Errorf("label %q is longer than 63 octets", l)
}
if l == "*" {
if !wildcardOK {
return errors.New("wildcard labels are not allowed here")
}
if i != 0 {
return errors.New("a wildcard (*) must be the leftmost label")
}
continue
}
for j := 0; j < len(l); j++ {
c := l[j]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '_', c == '/':
case c >= 'A' && c <= 'Z':
return fmt.Errorf("label %q must be lowercase", l)
case c == '*':
return fmt.Errorf("label %q: '*' is only allowed as a whole leftmost label", l)
default:
return fmt.Errorf("label %q contains an invalid character %q", l, c)
}
}
}
return nil
}
// IsHostname reports whether name follows the RFC 952/1123 host name rules
// that BIND's check-names option enforces: every label starts and ends with
// a letter or digit and contains only letters, digits and hyphens. If
// wildcardOK is set, a leftmost "*" label is accepted. The root name is not
// a host name, except "." used as a "no service" target where callers allow it.
func IsHostname(name string, wildcardOK bool) bool {
if name == Root || name == "" {
return false
}
labels := dns.SplitDomainName(name)
for i, l := range labels {
if i == 0 && wildcardOK && l == "*" {
continue
}
if !isHostLabel(l) {
return false
}
}
return true
}
func isHostLabel(l string) bool {
if l == "" || len(l) > 63 {
return false
}
for j := 0; j < len(l); j++ {
c := l[j]
alnum := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
if j == 0 || j == len(l)-1 {
if !alnum {
return false
}
} else if !alnum && c != '-' {
return false
}
}
return true
}
// IsMailbox reports whether name is a valid mailbox domain name, as BIND's
// check-names applies to the SOA RNAME: the first label may contain any
// printable ASCII character (it is the local part), the rest must be a host
// name.
func IsMailbox(name string) bool {
labels := dns.SplitDomainName(name)
if len(labels) < 2 {
return false
}
for _, c := range []byte(labels[0]) {
if c <= 0x20 || c >= 0x7f {
return false
}
}
return IsHostname(dns.Fqdn(strings.Join(labels[1:], ".")), false)
}
// MailboxFromEmail converts an email address ("first.last@example.com") to
// the domain-name form used in the SOA RNAME field
// ("first\.last.example.com."). Strings that are already in domain-name form
// (no "@") are returned as an FQDN.
func MailboxFromEmail(s string) (string, error) {
s = strings.TrimSpace(s)
at := strings.LastIndexByte(s, '@')
if at < 0 {
return Fqdn(s), nil
}
local, domain := s[:at], s[at+1:]
if local == "" || domain == "" {
return "", fmt.Errorf("invalid email address %q", s)
}
local = strings.ReplaceAll(local, `\`, `\\`)
local = strings.ReplaceAll(local, ".", `\.`)
return Fqdn(local + "." + domain), nil
}
// EmailFromMailbox converts an SOA RNAME back into email address form.
func EmailFromMailbox(rname string) string {
labels := dns.SplitDomainName(rname)
if len(labels) < 2 {
return Display(rname)
}
local := strings.ReplaceAll(labels[0], `\.`, ".")
return local + "@" + strings.Join(labels[1:], ".")
}
// IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
func IsReverse(name string) bool {
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
}
// ReverseName returns the PTR owner name for addr, for example
// "10.2.0.192.in-addr.arpa.".
func ReverseName(addr netip.Addr) string {
addr = addr.Unmap()
if addr.Is4() {
b := addr.As4()
return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa.", b[3], b[2], b[1], b[0])
}
b := addr.As16()
var sb strings.Builder
const hex = "0123456789abcdef"
for i := 15; i >= 0; i-- {
sb.WriteByte(hex[b[i]&0xf])
sb.WriteByte('.')
sb.WriteByte(hex[b[i]>>4])
sb.WriteByte('.')
}
sb.WriteString("ip6.arpa.")
return sb.String()
}
// MaxReverseZones is the most zones ReverseZones splits a network into.
const MaxReverseZones = 16
// ReverseZones returns the reverse zones that together cover prefix.
// Reverse names follow octet (IPv4) or nibble (IPv6) boundaries, so a
// prefix in between is covered by several zones: a /22 by four /24 zones.
// A single address (/32, /128) gets a zone of its own, which answers for
// just that address. IPv4 networks smaller than a /24 (/25 to /31) are
// refused: their names live in the enclosing /24 zone.
func ReverseZones(p netip.Prefix) ([]string, error) {
p = p.Masked()
addr := p.Addr()
bits := p.Bits()
step := 4
if addr.Is4() {
step = 8
switch {
case bits < 8:
return nil, fmt.Errorf("/%d is too large for a reverse zone; the largest IPv4 reverse zone is a /8", bits)
case bits > 24 && bits < 32:
enclosing, _ := addr.Prefix(24)
return nil, fmt.Errorf("a /%d is part of %s, and its reverse names live in that /24's zone; create %s, or a /32 zone per address to answer for single addresses only", bits, enclosing, enclosing)
}
} else if bits < 4 {
return nil, fmt.Errorf("/%d is too large for a reverse zone", bits)
}
zoneBits := (bits + step - 1) / step * step
n := 1 << (zoneBits - bits)
if n > MaxReverseZones {
return nil, fmt.Errorf("a /%d spans %d /%d reverse zones; use a /%d or create the zones you need", bits, n, zoneBits, zoneBits-step)
}
full := ReverseName(addr)
labels := strings.Split(strings.TrimSuffix(strings.TrimSuffix(full, ".in-addr.arpa."), ".ip6.arpa."), ".")
suffix := ".ip6.arpa."
if addr.Is4() {
suffix = ".in-addr.arpa."
}
// labels are least significant first; the zone keeps zoneBits/step of
// the most significant ones.
keep := zoneBits / step
zones := make([]string, 0, n)
for i := 0; i < n; i++ {
zl := append([]string(nil), labels[len(labels)-keep:]...)
if n > 1 {
// The last zone label counts through the network's range.
if addr.Is4() {
v, _ := strconv.Atoi(zl[0])
zl[0] = strconv.Itoa(v + i)
} else {
v := strings.IndexByte("0123456789abcdef", zl[0][0])
zl[0] = string("0123456789abcdef"[v+i])
}
}
zones = append(zones, strings.Join(zl, ".")+suffix)
}
return zones, nil
}
// ReverseZone returns the name of the reverse zone covering prefix, or an
// error when the prefix needs several zones (see ReverseZones).
func ReverseZone(p netip.Prefix) (string, error) {
zones, err := ReverseZones(p)
if err != nil {
return "", err
}
if len(zones) > 1 {
return "", fmt.Errorf("%s spans %d reverse zones (%s ... %s); create each of them, or use `jarl zone create %s`, which does", p.Masked(), len(zones), Display(zones[0]), Display(zones[len(zones)-1]), p.Masked())
}
return zones[0], nil
}
// AddrFromReverse parses a full reverse-lookup owner name back into an
// address. It returns false for names that don't name a single address.
func AddrFromReverse(name string) (netip.Addr, bool) {
switch {
case strings.HasSuffix(name, ".in-addr.arpa."):
parts := strings.Split(strings.TrimSuffix(name, ".in-addr.arpa."), ".")
if len(parts) != 4 {
return netip.Addr{}, false
}
s := parts[3] + "." + parts[2] + "." + parts[1] + "." + parts[0]
a, err := netip.ParseAddr(s)
return a, err == nil
case strings.HasSuffix(name, ".ip6.arpa."):
parts := strings.Split(strings.TrimSuffix(name, ".ip6.arpa."), ".")
if len(parts) != 32 {
return netip.Addr{}, false
}
var b [16]byte
for i, n := range parts {
if len(n) != 1 {
return netip.Addr{}, false
}
v := strings.IndexByte("0123456789abcdef", n[0])
if v < 0 {
return netip.Addr{}, false
}
pos := 31 - i
if pos%2 == 0 {
b[pos/2] |= byte(v) << 4
} else {
b[pos/2] |= byte(v)
}
}
return netip.AddrFrom16(b), true
}
return netip.Addr{}, false
}
// FileSafe turns a zone name into something safe to use as a file name.
func FileSafe(name string) string {
if name == Root {
return "root"
}
s := strings.TrimSuffix(name, ".")
var sb strings.Builder
for _, c := range []byte(s) {
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '.', c == '_':
sb.WriteByte(c)
default:
fmt.Fprintf(&sb, "%%%02x", c)
}
}
return sb.String()
}
// BestZone returns the longest name in zones that name falls within, or ""
// if none.
func BestZone(name string, zones []string) string {
best := ""
for _, z := range zones {
if IsSubdomain(name, z) && (best == "" || len(z) > len(best)) {
best = z
}
}
return best
}
// PrefixFromReverse returns the network a reverse zone answers for:
// 10.0.10.in-addr.arpa. is 10.0.10.0/24, a /32 zone a single address, and
// an ip6.arpa zone of 12 nibbles a /48. False for names that aren't whole
// octets or nibbles (classless delegations such as 0-63.1.0.10.in-addr.arpa.).
func PrefixFromReverse(zone string) (netip.Prefix, bool) {
zone = strings.ToLower(Fqdn(zone))
switch {
case strings.HasSuffix(zone, ".in-addr.arpa."):
labels := strings.Split(strings.TrimSuffix(zone, ".in-addr.arpa."), ".")
if len(labels) > 4 {
return netip.Prefix{}, false
}
var b [4]byte
for i, l := range labels {
v, err := strconv.Atoi(l)
if err != nil || v < 0 || v > 255 || strconv.Itoa(v) != l {
return netip.Prefix{}, false
}
b[len(labels)-1-i] = byte(v)
}
return netip.PrefixFrom(netip.AddrFrom4(b), 8*len(labels)), true
case strings.HasSuffix(zone, ".ip6.arpa."):
labels := strings.Split(strings.TrimSuffix(zone, ".ip6.arpa."), ".")
if len(labels) > 32 {
return netip.Prefix{}, false
}
var b [16]byte
for i, l := range labels {
v := strings.IndexByte("0123456789abcdef", l[0])
if len(l) != 1 || v < 0 {
return netip.Prefix{}, false
}
n := len(labels) - 1 - i // nibble position from the left
if n%2 == 0 {
b[n/2] |= byte(v) << 4
} else {
b[n/2] |= byte(v)
}
}
return netip.PrefixFrom(netip.AddrFrom16(b), 4*len(labels)), true
}
return netip.Prefix{}, false
}