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:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+446
View File
@@ -0,0 +1,446 @@
// Package rr parses, validates and canonicalizes resource record data.
//
// Record data is given in zone file (presentation) format, exactly as it
// would appear in a BIND zone file after the type, for example
// "10 mail.example.com." for MX. Names without a trailing dot are relative
// to the zone, as in zone files. TXT data that does not start with a quote
// is treated as plain text and quoted (and split into 255-byte strings)
// automatically.
package rr
import (
"encoding/hex"
"errors"
"fmt"
"reflect"
"regexp"
"strings"
"unicode/utf8"
"github.com/miekg/dns"
"jarl/internal/dnsname"
)
// Parsed is a successfully parsed record.
type Parsed struct {
RR dns.RR
Type string // canonical type mnemonic, e.g. "A" or "TYPE65280"
Data string // canonical presentation-format RDATA
Warnings []string
// Targets are the domain names in the RDATA (CNAME target, MX
// exchange, NS name, ...), canonical.
Targets []string
}
// Errors.
var (
ErrEmptyData = errors.New("record data is empty")
)
// forbidden lists types that cannot be created by users, with the reason.
var forbidden = map[uint16]string{
dns.TypeSOA: "the SOA record is managed by Jarl from the zone's settings",
dns.TypeOPT: "OPT is a pseudo-record",
dns.TypeTSIG: "TSIG is a pseudo-record",
dns.TypeTKEY: "TKEY is a pseudo-record",
dns.TypeANY: "ANY is a query type",
dns.TypeAXFR: "AXFR is a query type",
dns.TypeIXFR: "IXFR is a query type",
dns.TypeRRSIG: "DNSSEC signatures are maintained by named (dnssec-policy)",
dns.TypeNSEC: "NSEC records are maintained by named (dnssec-policy)",
dns.TypeNSEC3: "NSEC3 records are maintained by named (dnssec-policy)",
dns.TypeNSEC3PARAM: "NSEC3PARAM is maintained by named (dnssec-policy)",
dns.TypeDNSKEY: "DNSKEY records are maintained by named (dnssec-policy)",
dns.TypeCDS: "CDS records are maintained by named (dnssec-policy)",
dns.TypeCDNSKEY: "CDNSKEY records are maintained by named (dnssec-policy)",
dns.TypeSIG: "SIG is obsolete",
dns.TypeNXT: "NXT is obsolete",
dns.TypeDLV: "DLV is obsolete",
dns.TypeMD: "MD is obsolete (use MX)",
dns.TypeMF: "MF is obsolete (use MX)",
dns.TypeZONEMD: "ZONEMD would be wrong after any change; it is not supported",
dns.TypeNXNAME: "NXNAME is a meta type",
dns.TypeMAILA: "MAILA is a query type",
dns.TypeMAILB: "MAILB is a query type",
}
// NormalizeType returns the canonical mnemonic for a type given in any case
// (or as TYPEnnn), and its code.
func NormalizeType(t string) (string, uint16, error) {
u := strings.ToUpper(strings.TrimSpace(t))
if u == "" {
return "", 0, errors.New("record type is empty")
}
code, ok := dns.StringToType[u]
if !ok {
if n, ok2 := strings.CutPrefix(u, "TYPE"); ok2 {
var v uint64
if _, err := fmt.Sscanf(n, "%d", &v); err == nil && v > 0 && v < 65536 && fmt.Sprint(v) == n {
code = uint16(v)
if s, known := dns.TypeToString[code]; known {
u = s
}
ok = true
}
}
}
if !ok {
return "", 0, fmt.Errorf("unknown record type %q", t)
}
if why, bad := forbidden[code]; bad {
return "", 0, fmt.Errorf("records of type %s can't be managed: %s", u, why)
}
if code == 0 || (code >= 128 && code <= 255) || code == 65535 {
return "", 0, fmt.Errorf("type %s is a meta or reserved type", u)
}
return u, code, nil
}
// IsTextType reports whether data for the type is character strings that
// may be given as plain text.
func IsTextType(t string) bool { return t == "TXT" || t == "SPF" }
// QuoteText converts plain text into one or more quoted character
// strings of at most 255 bytes each, escaping quotes and backslashes.
// Multi-byte UTF-8 sequences are not split between strings.
func QuoteText(text string) string {
var parts []string
var cur strings.Builder
n := 0 // raw bytes in current chunk
flush := func() {
parts = append(parts, `"`+cur.String()+`"`)
cur.Reset()
n = 0
}
for i := 0; i < len(text); {
r, size := utf8.DecodeRuneInString(text[i:])
if n+size > 255 {
flush()
}
chunk := text[i : i+size]
switch {
case r == '"' || r == '\\':
cur.WriteByte('\\')
cur.WriteString(chunk)
case size == 1 && (chunk[0] < 0x20 || chunk[0] == 0x7f):
fmt.Fprintf(&cur, "\\%03d", chunk[0])
default:
cur.WriteString(chunk)
}
n += size
i += size
}
if n > 0 || len(parts) == 0 {
flush()
}
return strings.Join(parts, " ")
}
// TextValue returns the concatenated, unescaped text of TXT-style data.
func TextValue(r dns.RR) (string, bool) {
switch v := r.(type) {
case *dns.TXT:
return unescapeAll(v.Txt), true
case *dns.SPF:
return unescapeAll(v.Txt), true
}
return "", false
}
func unescapeAll(ss []string) string {
var sb strings.Builder
for _, s := range ss {
sb.WriteString(unescapeTxt(s))
}
return sb.String()
}
// unescapeTxt undoes miekg/dns' presentation escaping of a TXT string.
func unescapeTxt(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var out []byte
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
if i+3 < len(s) && isDigit(s[i+1]) && isDigit(s[i+2]) && isDigit(s[i+3]) {
out = append(out, byte((s[i+1]-'0')*100+(s[i+2]-'0')*10+(s[i+3]-'0')))
i += 3
continue
}
out = append(out, s[i+1])
i++
continue
}
out = append(out, s[i])
}
return string(out)
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
// Parse parses and validates record data. zone and owner must be canonical
// FQDNs; typ is the record type in any case.
func Parse(zone, owner, typ, data string) (*Parsed, error) {
t, code, err := NormalizeType(typ)
if err != nil {
return nil, err
}
d := strings.TrimSpace(data)
if d == "" {
return nil, ErrEmptyData
}
for i := 0; i < len(d); i++ {
if c := d[i]; c < 0x20 && c != '\t' || c == 0x7f {
return nil, errors.New("record data must not contain line breaks or control characters")
}
}
if strings.HasPrefix(d, "$") {
return nil, errors.New("record data must not start with '$' (zone file directives are not allowed)")
}
if IsTextType(t) && !strings.HasPrefix(d, `"`) {
d = QuoteText(d)
}
typeText := t
if _, known := dns.TypeToString[code]; !known {
typeText = fmt.Sprintf("TYPE%d", code)
}
line := fmt.Sprintf("%s 3600 IN %s %s\n", owner, typeText, d)
zp := dns.NewZoneParser(strings.NewReader(line), zone, "")
zp.SetIncludeAllowed(false)
parsed, ok := zp.Next()
if perr := zp.Err(); perr != nil {
return nil, cleanParseError(perr, t)
}
if !ok || parsed == nil {
return nil, fmt.Errorf("could not parse %s data %q", t, data)
}
if c := zp.Comment(); c != "" {
return nil, errors.New("';' starts a comment in zone file syntax; put comments in the record's comment field (or quote the text)")
}
if extra, more := zp.Next(); more || extra != nil {
return nil, errors.New("record data contains more than one record")
}
if parsed.Header().Rrtype != code {
return nil, fmt.Errorf("data does not form a %s record", t)
}
p := &Parsed{RR: parsed, Type: t}
if err := p.fixNames(zone, data); err != nil {
return nil, err
}
if err := p.check(owner); err != nil {
return nil, err
}
// Ensure the record fits in a DNS message.
buf := make([]byte, 65535+512)
if _, err := dns.PackRR(p.RR, buf, 0, nil, false); err != nil {
return nil, fmt.Errorf("record data is too large or invalid: %v", err)
}
// RR.String() is "owner\tttl\tclass\ttype\tdata".
fields := strings.SplitN(p.RR.String(), "\t", 5)
if len(fields) == 5 {
p.Data = strings.TrimSpace(fields[4])
}
if p.Data == "" {
return nil, ErrEmptyData
}
return p, nil
}
var badFieldRe = regexp.MustCompile(`^bad (\S+) (\S+): (.*)$`)
func cleanParseError(err error, t string) error {
msg := err.Error()
// miekg/dns errors look like: dns: bad A A: "1.2.3" at line: 1:20
msg = strings.TrimPrefix(msg, "dns: ")
if i := strings.Index(msg, " at line: "); i >= 0 {
msg = msg[:i]
}
// "bad A A: "1.2.3"" names the type and then the field; say it once.
if m := badFieldRe.FindStringSubmatch(msg); m != nil && strings.EqualFold(m[1], t) {
if strings.EqualFold(m[2], t) {
msg = "bad value " + m[3]
} else {
msg = "bad " + m[2] + " " + m[3]
}
}
return fmt.Errorf("invalid %s data: %s", t, msg)
}
// nameField is a domain-name field of an RR struct.
type nameField struct {
v reflect.Value
name string
}
func domainFields(rr dns.RR) []nameField {
var out []nameField
var walk func(v reflect.Value)
walk = func(v reflect.Value) {
t := v.Type()
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fv := v.Field(i)
if f.Anonymous && f.Type.Kind() == reflect.Struct && f.Type != reflect.TypeOf(dns.RR_Header{}) {
walk(fv)
continue
}
tag := f.Tag.Get("dns")
if strings.Contains(tag, "domain-name") && fv.Kind() == reflect.String {
out = append(out, nameField{fv, f.Name})
}
}
}
walk(reflect.ValueOf(rr).Elem())
return out
}
// fixNames lowercases RDATA domain names and repairs the classic mistake
// of writing an absolute name without the trailing dot when it already
// ends in the zone name ("www.example.com" in zone example.com would
// otherwise become "www.example.com.example.com.").
func (p *Parsed) fixNames(zone, input string) error {
_ = input
zoneNoDot := strings.TrimSuffix(zone, ".")
dup := zoneNoDot + "." + zone // "example.com.example.com."
for _, f := range domainFields(p.RR) {
n := strings.ToLower(f.v.String())
if zone != dnsname.Root && (n == dup || strings.HasSuffix(n, "."+dup)) {
fixed := strings.TrimSuffix(n, zone)
p.Warnings = append(p.Warnings, fmt.Sprintf("%q was read as the absolute name %q (add a trailing dot to be explicit)", strings.TrimSuffix(fixed, "."), fixed))
n = fixed
}
f.v.SetString(n)
p.Targets = append(p.Targets, n)
}
return nil
}
// check applies type-specific rules, most of them the same ones BIND's
// check-names and integrity checks apply when it loads a primary zone.
func (p *Parsed) check(owner string) error {
hostTarget := func(field, name string, allowRoot bool) error {
if name == "." {
if allowRoot {
return nil
}
return fmt.Errorf("%s must not be the root name", field)
}
if looksLikeIPv4Prefix(name) {
return fmt.Errorf("%s must be a host name, not an IP address (%q)", field, dnsname.Display(name))
}
if !dnsname.IsHostname(name, false) {
return fmt.Errorf("%s %q is not a valid host name (letters, digits and hyphens only; check-names would reject it)", field, dnsname.Display(name))
}
return nil
}
switch v := p.RR.(type) {
case *dns.A:
if !dnsname.IsHostname(owner, true) {
return fmt.Errorf("the owner of an A record must be a valid host name (no underscores); %q would be rejected by check-names", dnsname.Display(owner))
}
case *dns.AAAA:
if !dnsname.IsHostname(owner, true) {
return fmt.Errorf("the owner of an AAAA record must be a valid host name (no underscores); %q would be rejected by check-names", dnsname.Display(owner))
}
if v.AAAA.To4() != nil && strings.Contains(v.AAAA.String(), ".") {
p.Warnings = append(p.Warnings, "AAAA record contains an IPv4-mapped address")
}
case *dns.MX:
if !dnsname.IsHostname(owner, true) {
return fmt.Errorf("the owner of an MX record must be a valid host name; %q would be rejected by check-names", dnsname.Display(owner))
}
if v.Mx == "." {
if v.Preference != 0 {
return errors.New(`a "null MX" (exchange ".") must have preference 0 (RFC 7505)`)
}
return nil
}
return hostTarget("MX exchange", v.Mx, false)
case *dns.NS:
return hostTarget("NS target", v.Ns, false)
case *dns.SRV:
if err := hostTarget("SRV target", v.Target, true); err != nil {
return err
}
labels := dns.SplitDomainName(owner)
if len(labels) < 3 || !strings.HasPrefix(labels[0], "_") || !strings.HasPrefix(labels[1], "_") {
p.Warnings = append(p.Warnings, "SRV owner names should look like _service._proto.name (RFC 2782)")
}
case *dns.PTR:
if dnsname.IsReverse(owner) {
return hostTarget("PTR target", v.Ptr, false)
}
case *dns.CNAME:
if v.Target == "." {
return errors.New("CNAME target must not be the root name")
}
case *dns.DNAME:
if v.Target == "." {
return errors.New("DNAME target must not be the root name")
}
case *dns.CAA:
switch v.Tag {
case "issue", "issuewild", "iodef", "issuemail", "issuevmc", "contactemail", "contactphone":
default:
p.Warnings = append(p.Warnings, fmt.Sprintf("unknown CAA property tag %q", v.Tag))
}
if v.Flag != 0 && v.Flag != 128 {
p.Warnings = append(p.Warnings, "CAA flags should be 0 or 128 (issuer critical)")
}
case *dns.DS:
if err := checkDigest("DS", v.DigestType, v.Digest, map[uint8]int{1: 20, 2: 32, 4: 48}); err != nil {
return err
}
case *dns.SSHFP:
if err := checkDigest("SSHFP", v.Type, v.FingerPrint, map[uint8]int{1: 20, 2: 32}); err != nil {
return err
}
case *dns.TLSA:
if err := checkDigest("TLSA", v.MatchingType, v.Certificate, map[uint8]int{1: 32, 2: 64}); err != nil {
return err
}
labels := dns.SplitDomainName(owner)
if len(labels) < 3 || !strings.HasPrefix(labels[0], "_") || !strings.HasPrefix(labels[1], "_") {
p.Warnings = append(p.Warnings, "TLSA owner names should look like _port._proto.name (RFC 6698)")
}
case *dns.SPF:
p.Warnings = append(p.Warnings, "the SPF record type is deprecated (RFC 7208); publish SPF policies as TXT records")
}
return nil
}
func looksLikeIPv4Prefix(name string) bool {
// "10.0.0.1.example.com." from "MX 10 10.0.0.1": four numeric labels.
labels := dns.SplitDomainName(name)
if len(labels) < 4 {
return false
}
for _, l := range labels[:4] {
if l == "" || len(l) > 3 {
return false
}
for i := 0; i < len(l); i++ {
if !isDigit(l[i]) {
return false
}
}
}
return true
}
func checkDigest(t string, digestType uint8, digest string, sizes map[uint8]int) error {
want, known := sizes[digestType]
if !known {
return nil
}
b, err := hex.DecodeString(digest)
if err != nil {
return fmt.Errorf("%s digest is not valid hex", t)
}
if len(b) != want {
return fmt.Errorf("%s digest type %d needs %d bytes (%d hex digits), got %d bytes", t, digestType, want, want*2, len(b))
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package rr
import (
"strings"
"testing"
)
const zone = "example.com."
func TestParseCanonical(t *testing.T) {
cases := []struct {
owner, typ, data, want string
}{
{"www.example.com.", "a", "192.0.2.1", "192.0.2.1"},
{"www.example.com.", "AAAA", "2001:DB8:0:0::1", "2001:db8::1"},
{"www.example.com.", "CNAME", "web", "web.example.com."},
{"www.example.com.", "CNAME", "Web.Example.Org.", "web.example.org."},
{"www.example.com.", "CNAME", "host.example.com", "host.example.com."},
{"example.com.", "MX", "10 mail", "10 mail.example.com."},
{"example.com.", "MX", "0 .", "0 ."},
{"example.com.", "TXT", "v=spf1 mx -all", `"v=spf1 mx -all"`},
{"example.com.", "TXT", `"a b" "c"`, `"a b" "c"`},
{"example.com.", "TXT", `say "hi" \o/`, `"say \"hi\" \\o/"`},
{"example.com.", "TXT", "semi; colon", `"semi; colon"`},
{"example.com.", "CAA", "0 issue letsencrypt.org", `0 issue "letsencrypt.org"`},
{"_sip._tcp.example.com.", "SRV", "0 5 5060 sip", "0 5 5060 sip.example.com."},
{"svc.example.com.", "HTTPS", `1 . alpn="h2,h3"`, `1 . alpn="h2,h3"`},
{"x.example.com.", "type65280", `\# 4 deadbeef`, `\# 4 deadbeef`},
{"10.2.0.192.in-addr.arpa.", "PTR", "host.example.com.", "host.example.com."},
{"_dmarc.example.com.", "TXT", "v=DMARC1; p=none", `"v=DMARC1; p=none"`},
{"*.example.com.", "A", "192.0.2.9", "192.0.2.9"},
}
for _, c := range cases {
p, err := Parse(zone, c.owner, c.typ, c.data)
if err != nil {
t.Errorf("Parse(%s %s %q): %v", c.owner, c.typ, c.data, err)
continue
}
if p.Data != c.want {
t.Errorf("Parse(%s %s %q) = %q, want %q", c.owner, c.typ, c.data, p.Data, c.want)
}
}
}
func TestParseWarnings(t *testing.T) {
p, err := Parse(zone, "www.example.com.", "CNAME", "host.example.com")
if err != nil || len(p.Warnings) != 1 || !strings.Contains(p.Warnings[0], "absolute name") {
t.Errorf("double origin warning missing: %+v %v", p, err)
}
p, err = Parse(zone, "sip.example.com.", "SRV", "0 5 5060 sip")
if err != nil || len(p.Warnings) != 1 {
t.Errorf("SRV owner warning missing: %+v %v", p, err)
}
}
func TestParseErrors(t *testing.T) {
cases := []struct {
owner, typ, data, want string
}{
{"www.example.com.", "A", "01.2.3.4", "invalid A data"},
{"www.example.com.", "A", "", "empty"},
{"www.example.com.", "SOA", "x", "managed by Jarl"},
{"www.example.com.", "RRSIG", "x", "maintained by named"},
{"www.example.com.", "BOGUS", "x", "unknown record type"},
{"foo_bar.example.com.", "A", "1.2.3.4", "valid host name"},
{"example.com.", "MX", "10 1.2.3.4", "not an IP address"},
{"example.com.", "MX", "10 bad_host.example.org.", "not a valid host name"},
{"example.com.", "MX", "5 .", "null MX"},
{"www.example.com.", "A", "1.2.3.4\n$INCLUDE /etc/passwd", "line breaks"},
{"www.example.com.", "A", "1.2.3.4 ; comment", "comment"},
{"www.example.com.", "A", "1.2.3.4 5.6.7.8", "invalid A data"},
{"www.example.com.", "DS", "12345 13 2 abcd", "needs 32 bytes"},
{"www.example.com.", "NS", "1.2.3.4.", "not an IP address"},
{"www.example.com.", "CNAME", ".", "root"},
{"www.example.com.", "A", "$ORIGIN x", "'$'"},
}
for _, c := range cases {
_, err := Parse(zone, c.owner, c.typ, c.data)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("Parse(%s %s %q) error = %v, want %q", c.owner, c.typ, c.data, err, c.want)
}
}
}
func TestQuoteText(t *testing.T) {
long := strings.Repeat("a", 600)
q := QuoteText(long)
if strings.Count(q, `"`) != 6 {
t.Errorf("600 bytes should be 3 strings: %q", q)
}
p, err := Parse(zone, "k._domainkey.example.com.", "TXT", "v=DKIM1; k=rsa; p="+long)
if err != nil {
t.Fatal(err)
}
v, _ := TextValue(p.RR)
if v != "v=DKIM1; k=rsa; p="+long {
t.Errorf("TextValue mismatch")
}
// Multi-byte characters are not split across strings.
s := strings.Repeat("é", 200) // 400 bytes
for _, part := range strings.Split(QuoteText(s), `" "`) {
if strings.ContainsRune(strings.Trim(part, `"`), '\uFFFD') {
t.Error("split inside a UTF-8 sequence")
}
}
p, err = Parse(zone, "example.com.", "TXT", `say "hi" \o/`)
if err != nil {
t.Fatal(err)
}
if v, _ := TextValue(p.RR); v != `say "hi" \o/` {
t.Errorf("TextValue = %q", v)
}
}
func TestTypes(t *testing.T) {
types := Types()
if types[0].Type != "A" || !types[0].Common {
t.Errorf("first type = %+v", types[0])
}
seen := map[string]bool{}
for _, ti := range types {
if seen[ti.Type] {
t.Errorf("duplicate %s", ti.Type)
}
seen[ti.Type] = true
if _, _, err := NormalizeType(ti.Type); err != nil {
t.Errorf("listed type %s not accepted: %v", ti.Type, err)
}
if ti.Example != "" {
owner := "x.example.com."
switch ti.Type {
case "SRV":
owner = "_sip._tcp.example.com."
case "TLSA":
owner = "_443._tcp.example.com."
}
if _, err := Parse(zone, owner, ti.Type, ti.Example); err != nil {
t.Errorf("example for %s fails: %v", ti.Type, err)
}
}
}
for _, bad := range []string{"SOA", "RRSIG", "OPT", "DNSKEY"} {
if seen[bad] {
t.Errorf("forbidden type %s listed", bad)
}
}
}
func TestParseErrorText(t *testing.T) {
for _, tc := range []struct{ typ, data, want string }{
{"A", "192.168.1.300", `invalid A data: bad value "192.168.1.300"`},
{"MX", "x mail", `invalid MX data: bad Pref "x"`},
} {
_, err := Parse("example.com.", "www.example.com.", tc.typ, tc.data)
if err == nil || err.Error() != tc.want {
t.Errorf("Parse(%s %q) error = %v, want %q", tc.typ, tc.data, err, tc.want)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package rr
import (
"sort"
"github.com/miekg/dns"
)
// TypeInfo describes a record type for the web UI and CLI.
type TypeInfo struct {
Type string `json:"type"`
Code uint16 `json:"code"`
Common bool `json:"common"`
Description string `json:"description"`
Format string `json:"format,omitempty"`
Example string `json:"example,omitempty"`
}
var described = []TypeInfo{
{Type: "A", Common: true, Description: "IPv4 address", Format: "<IPv4 address>", Example: "192.0.2.10"},
{Type: "AAAA", Common: true, Description: "IPv6 address", Format: "<IPv6 address>", Example: "2001:db8::10"},
{Type: "CNAME", Common: true, Description: "Alias for another name (no other records may exist at the same name)", Format: "<target name>", Example: "www.example.com."},
{Type: "MX", Common: true, Description: "Mail exchanger", Format: "<preference> <mail server host name>", Example: "10 mail.example.com."},
{Type: "TXT", Common: true, Description: "Text. Plain text is quoted and split into 255-byte strings automatically; start with a quote to give presentation format.", Format: "<text> or \"string\" \"string\"", Example: "v=spf1 mx -all"},
{Type: "NS", Common: true, Description: "Name server for the zone (at the apex) or a delegation (below it)", Format: "<name server host name>", Example: "ns1.example.com."},
{Type: "PTR", Common: true, Description: "Reverse mapping (in in-addr.arpa / ip6.arpa zones)", Format: "<host name>", Example: "host.example.com."},
{Type: "SRV", Common: true, Description: "Service location (owner _service._proto.name)", Format: "<priority> <weight> <port> <target>", Example: "0 5 5060 sip.example.com."},
{Type: "CAA", Common: true, Description: "Certification authority authorization", Format: "<flags> <tag> <value>", Example: "0 issue \"letsencrypt.org\""},
{Type: "HTTPS", Common: true, Description: "HTTPS service binding (RFC 9460)", Format: "<priority> <target> [params...]", Example: "1 . alpn=\"h2,h3\""},
{Type: "SVCB", Description: "General service binding (RFC 9460)", Format: "<priority> <target> [params...]", Example: "1 svc.example.com. alpn=\"h2\" port=8443"},
{Type: "SSHFP", Description: "SSH host key fingerprint", Format: "<algorithm> <fp type> <fingerprint hex>", Example: "4 2 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},
{Type: "TLSA", Description: "TLS certificate association, DANE (owner _port._proto.name)", Format: "<usage> <selector> <matching type> <data hex>", Example: "3 1 1 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
{Type: "DS", Description: "Delegation signer for a signed child zone (at a delegation point)", Format: "<key tag> <algorithm> <digest type> <digest hex>", Example: "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
{Type: "DNAME", Description: "Redirects a whole subtree to another name", Format: "<target name>", Example: "other.example.net."},
{Type: "NAPTR", Description: "Naming authority pointer", Format: "<order> <preference> \"<flags>\" \"<service>\" \"<regexp>\" <replacement>", Example: "100 10 \"u\" \"E2U+sip\" \"!^.*$!sip:info@example.com!\" ."},
{Type: "LOC", Description: "Geographical location", Format: "<lat> <lon> <alt>m [size [hp [vp]]]", Example: "52 22 23.000 N 4 53 32.000 E -2.00m 0.00m 10000m 10m"},
{Type: "HINFO", Description: "Host information", Format: "\"<cpu>\" \"<os>\"", Example: "\"PC\" \"Linux\""},
{Type: "RP", Description: "Responsible person", Format: "<mailbox name> <TXT record name>", Example: "admin.example.com. ."},
{Type: "URI", Description: "Uniform resource identifier", Format: "<priority> <weight> \"<uri>\"", Example: "10 1 \"https://example.com/\""},
{Type: "OPENPGPKEY", Description: "OpenPGP public key (DANE)", Format: "<base64 key>"},
{Type: "SMIMEA", Description: "S/MIME certificate association", Format: "<usage> <selector> <matching type> <data hex>"},
{Type: "CERT", Description: "Certificate", Format: "<type> <key tag> <algorithm> <base64 certificate>"},
{Type: "DHCID", Description: "DHCP identifier", Format: "<base64 data>"},
{Type: "AFSDB", Description: "AFS database location", Format: "<subtype> <host name>", Example: "1 afs.example.com."},
{Type: "APL", Description: "Address prefix list", Format: "[!]<family>:<prefix>/<len> ...", Example: "1:192.168.32.0/21"},
{Type: "IPSECKEY", Description: "IPsec key", Format: "<precedence> <gateway type> <algorithm> <gateway> <base64 key>"},
{Type: "KX", Description: "Key exchanger", Format: "<preference> <host name>", Example: "10 kx.example.com."},
{Type: "KEY", Description: "Public key (SIG(0) and other legacy uses)", Format: "<flags> <protocol> <algorithm> <base64 key>"},
{Type: "SPF", Description: "Deprecated SPF record type; publish SPF policies as TXT instead", Format: "\"v=spf1 ...\""},
{Type: "CSYNC", Description: "Child-to-parent synchronization", Format: "<soa serial> <flags> <type list>", Example: "66 3 A NS AAAA"},
{Type: "EUI48", Description: "48-bit MAC address", Format: "xx-xx-xx-xx-xx-xx", Example: "00-00-5e-00-53-2a"},
{Type: "EUI64", Description: "64-bit EUI address", Format: "xx-xx-xx-xx-xx-xx-xx-xx", Example: "00-00-5e-ef-10-00-00-2a"},
{Type: "RESINFO", Description: "Resolver information (RFC 9606)", Format: "key=value ...", Example: "qnamemin exterr=15,16,17"},
}
// Types returns information about every record type that can be managed,
// common types first.
func Types() []TypeInfo {
seen := map[string]bool{}
var out []TypeInfo
for _, t := range described {
code, ok := dns.StringToType[t.Type]
if !ok {
continue
}
t.Code = code
out = append(out, t)
seen[t.Type] = true
}
var rest []TypeInfo
for code, name := range dns.TypeToString {
if seen[name] {
continue
}
if _, ok := dns.TypeToRR[code]; !ok {
continue
}
if _, bad := forbidden[code]; bad {
continue
}
rest = append(rest, TypeInfo{Type: name, Code: code, Description: name + " record (zone file format)"})
}
sort.Slice(rest, func(i, j int) bool { return rest[i].Type < rest[j].Type })
return append(out, rest...)
}