// 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" "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 // ".", 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() } // ReverseZone returns the name of the reverse zone covering prefix. IPv4 // prefixes must be /8, /16 or /24 and IPv6 prefixes a multiple of /4; other // lengths need RFC 2317 style delegation, which must be set up by hand. func ReverseZone(p netip.Prefix) (string, error) { p = p.Masked() addr := p.Addr() bits := p.Bits() if addr.Is4() { if bits != 8 && bits != 16 && bits != 24 { return "", fmt.Errorf("IPv4 reverse zones must be /8, /16 or /24 (got /%d); use the enclosing /24 zone instead", bits) } b := addr.As4() parts := []string{} for i := bits/8 - 1; i >= 0; i-- { parts = append(parts, fmt.Sprint(b[i])) } return strings.Join(parts, ".") + ".in-addr.arpa.", nil } if bits%4 != 0 || bits == 0 { return "", fmt.Errorf("IPv6 reverse zones must be a multiple of /4 (got /%d)", bits) } full := ReverseName(addr) // 32 nibbles nibbles := strings.Split(strings.TrimSuffix(full, ".ip6.arpa."), ".") keep := nibbles[len(nibbles)-bits/4:] return strings.Join(keep, ".") + ".ip6.arpa.", 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 }