Files
jarl/internal/dnsname/dnsname.go
T
abrown38andClaude Opus 5.5 bf7f99874b Web UI: zone naming and reverse zones, ACL suggestions, record form
- New zone dialog: choose Domain or Reverse zone. Reverse zones are
  created from a network and their name is generated; both kinds show the
  exact zone name and flag likely mistakes (addresses or URLs as names,
  host-like names, duplicates in the view, parent/child zones, networks
  off the octet/nibble boundaries) before anything is submitted.
- Record form: clears every field but the type after an add; shows the
  record exactly as it will be stored and warns about unqualified names.
  In reverse zones it takes an address, defaults to PTR, suggests host
  names of A/AAAA records with that address and warns about a second PTR;
  the record list shows addresses.
- Address match lists (options, view match lists, ACL elements) offer the
  existing ACLs, TSIG keys and built-ins as one-click suggestions and
  flag unknown names.
- Server: in reverse zones a record can be named by its address, and a
  PTR target like nas.example.com without the final dot is read as the
  full host name (a bare label is refused). Warnings noticed while parsing
  record data reach the result again.
- Fixes: brand link hover underline, the dashboard's "New zone" link
  opening the dialog twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:49:43 +00:00

427 lines
12 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"
"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()
}
// 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
}