Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -0,0 +1,420 @@
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user