- CLI: name several servers and groups in the config file; select them with -s/--server NAME|GROUP|all|URL or JARL_SERVER. A command aimed at several servers is dry-run on all of them first and only runs if every one accepts it. Named servers always use their own token. - Reverse zones: /32 and /128 get single-address zones; networks between octet or nibble boundaries (e.g. a /22) are covered by several zones created together, from the CLI and the web UI. - Fix: under jarl.service's RestrictSUIDSGID, creating a view directory failed on chmod with the setgid bit; fall back to the permission bits. - Web UI: the review/live switch is a small header button, and the pending-changes bar only shows when there are pending changes. - TSIG key wording; the keys page points to restricted API tokens. - Docs: several servers (cli.md), a secondary server (operations.md), tokens vs TSIG keys (permissions.md); CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
14 KiB
Go
472 lines
14 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
|
|
}
|