initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+541
View File
@@ -0,0 +1,541 @@
package validate
import (
"errors"
"fmt"
"net/netip"
"strconv"
"strings"
"github.com/miekg/dns"
)
// Field describes one input in a type-specific record editor. The web UI builds
// its forms from this metadata, so adding a record type here gives it a proper
// editor without writing a new template.
type Field struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // text, number, textarea, select
Placeholder string `json:"placeholder,omitempty"`
Help string `json:"help,omitempty"`
Required bool `json:"required"`
Quote bool `json:"quote,omitempty"` // rdata field must be a quoted string
Options []string `json:"options,omitempty"`
Width int `json:"width,omitempty"` // Bootstrap column width, 12-grid
}
// TypeInfo describes a record type and its editor.
type TypeInfo struct {
Type string `json:"type"`
Description string `json:"description"`
Fields []Field `json:"fields"`
Common bool `json:"common"`
}
func num(key, label, placeholder, help string, width int) Field {
return Field{Key: key, Label: label, Type: "number", Placeholder: placeholder,
Help: help, Required: true, Width: width}
}
func txt(key, label, placeholder, help string, width int) Field {
return Field{Key: key, Label: label, Type: "text", Placeholder: placeholder,
Help: help, Required: true, Width: width}
}
// recordTypes drives both validation and the UI editors.
var recordTypes = []TypeInfo{
{Type: "A", Description: "IPv4 address", Common: true, Fields: []Field{
txt("address", "IPv4 address", "192.0.2.10", "A single IPv4 address.", 12),
}},
{Type: "AAAA", Description: "IPv6 address", Common: true, Fields: []Field{
txt("address", "IPv6 address", "2001:db8::10", "A single IPv6 address.", 12),
}},
{Type: "CNAME", Description: "Canonical name alias", Common: true, Fields: []Field{
txt("target", "Target", "example.com.", "The name this record is an alias for. End with a dot for an absolute name.", 12),
}},
{Type: "MX", Description: "Mail exchanger", Common: true, Fields: []Field{
num("preference", "Preference", "10", "Lower values are preferred.", 3),
txt("exchange", "Mail server", "mail.example.com.", "Host name of the mail server. Must not be a CNAME.", 9),
}},
{Type: "TXT", Description: "Free-form text", Common: true, Fields: []Field{
{Key: "text", Label: "Text", Type: "textarea", Required: true, Quote: true, Width: 12,
Placeholder: "v=spf1 include:_spf.example.com ~all",
Help: "Quoting and 255-character chunking are handled automatically."},
}},
{Type: "NS", Description: "Name server delegation", Common: true, Fields: []Field{
txt("nameserver", "Name server", "ns1.example.com.", "Authoritative name server for this name.", 12),
}},
{Type: "SRV", Description: "Service location", Common: true, Fields: []Field{
num("priority", "Priority", "10", "Lower values are preferred.", 3),
num("weight", "Weight", "20", "Relative weight among equal priorities.", 3),
num("port", "Port", "5060", "TCP or UDP port of the service.", 3),
txt("target", "Target", "sip.example.com.", "Host providing the service.", 3),
}},
{Type: "PTR", Description: "Reverse pointer", Common: true, Fields: []Field{
txt("target", "Points to", "host.example.com.", "The host name this address belongs to.", 12),
}},
{Type: "CAA", Description: "Certificate authority authorisation", Common: true, Fields: []Field{
num("flags", "Flags", "0", "128 marks the property as critical.", 2),
{Key: "tag", Label: "Tag", Type: "select", Required: true, Width: 3,
Options: []string{"issue", "issuewild", "iodef", "contactemail", "contactphone"}},
{Key: "value", Label: "Value", Type: "text", Required: true, Quote: true, Width: 7,
Placeholder: "letsencrypt.org", Help: "The CA domain, or a mailto:/https: URL for iodef."},
}},
{Type: "SOA", Description: "Start of authority", Fields: []Field{
txt("ns", "Primary name server", "ns1.example.com.", "", 6),
txt("mbox", "Responsible party", "hostmaster.example.com.", "The @ in the email address becomes a dot.", 6),
num("serial", "Serial", "1", "", 4),
num("refresh", "Refresh", "7200", "", 4),
num("retry", "Retry", "3600", "", 4),
num("expire", "Expire", "1209600", "", 6),
num("minimum", "Minimum / negative TTL", "3600", "", 6),
}},
{Type: "NAPTR", Description: "Naming authority pointer", Fields: []Field{
num("order", "Order", "100", "", 3),
num("preference", "Preference", "10", "", 3),
{Key: "flags", Label: "Flags", Type: "text", Quote: true, Width: 3, Placeholder: "U"},
{Key: "service", Label: "Service", Type: "text", Quote: true, Width: 3, Placeholder: "E2U+sip"},
{Key: "regexp", Label: "Regexp", Type: "text", Quote: true, Width: 8,
Placeholder: `!^.*$!sip:info@example.com!`},
txt("replacement", "Replacement", ".", "Use a single dot when a regexp is given.", 4),
}},
{Type: "TLSA", Description: "TLS certificate association", Fields: []Field{
num("usage", "Usage", "3", "0-3; 3 is a domain-issued certificate.", 3),
num("selector", "Selector", "1", "0 full certificate, 1 public key.", 3),
num("matching_type", "Matching type", "1", "0 exact, 1 SHA-256, 2 SHA-512.", 3),
txt("certificate", "Certificate data", "abc123...", "Hex encoded association data.", 3),
}},
{Type: "SSHFP", Description: "SSH host key fingerprint", Fields: []Field{
num("algorithm", "Algorithm", "4", "1 RSA, 2 DSA, 3 ECDSA, 4 Ed25519.", 4),
num("type", "Fingerprint type", "2", "1 SHA-1, 2 SHA-256.", 4),
txt("fingerprint", "Fingerprint", "abc123...", "Hex encoded fingerprint.", 4),
}},
{Type: "SVCB", Description: "Service binding", Fields: []Field{
num("priority", "Priority", "1", "0 selects alias mode.", 3),
txt("target", "Target", "svc.example.com.", "", 4),
{Key: "params", Label: "Parameters", Type: "text", Width: 5,
Placeholder: "alpn=h2,h3 port=8443", Help: "Space separated key=value pairs."},
}},
{Type: "HTTPS", Description: "HTTPS service binding", Common: true, Fields: []Field{
num("priority", "Priority", "1", "0 selects alias mode.", 3),
txt("target", "Target", ".", "A single dot means the owner name itself.", 4),
{Key: "params", Label: "Parameters", Type: "text", Width: 5,
Placeholder: "alpn=h2,h3 ipv4hint=192.0.2.10", Help: "Space separated key=value pairs."},
}},
{Type: "DS", Description: "Delegation signer", Fields: []Field{
num("key_tag", "Key tag", "12345", "", 3),
num("algorithm", "Algorithm", "13", "8 RSASHA256, 13 ECDSAP256SHA256, 15 ED25519.", 3),
num("digest_type", "Digest type", "2", "1 SHA-1, 2 SHA-256, 4 SHA-384.", 3),
txt("digest", "Digest", "abc123...", "Hex encoded digest.", 3),
}},
{Type: "DNSKEY", Description: "DNSSEC public key", Fields: []Field{
num("flags", "Flags", "257", "256 zone signing key, 257 key signing key.", 3),
num("protocol", "Protocol", "3", "Always 3.", 3),
num("algorithm", "Algorithm", "13", "8 RSASHA256, 13 ECDSAP256SHA256, 15 ED25519.", 3),
{Key: "public_key", Label: "Public key", Type: "textarea", Required: true, Width: 12,
Placeholder: "base64 encoded key material"},
}},
{Type: "DNAME", Description: "Delegation name redirection", Fields: []Field{
txt("target", "Target", "example.net.", "Rewrites the entire subtree below this name.", 12),
}},
{Type: "SPF", Description: "Legacy sender policy (prefer TXT)", Fields: []Field{
{Key: "text", Label: "Policy", Type: "textarea", Required: true, Quote: true, Width: 12,
Placeholder: "v=spf1 mx ~all"},
}},
{Type: "LOC", Description: "Geographic location", Fields: []Field{
{Key: "raw", Label: "Location", Type: "text", Required: true, Width: 12,
Placeholder: "51 30 12.748 N 0 7 39.611 W 0.00m"},
}},
{Type: "RAW", Description: "Advanced: any record type, entered by hand", Fields: []Field{
{Key: "rtype", Label: "Record type", Type: "text", Required: true, Width: 4,
Placeholder: "URI", Help: "Any type name known to the DNS library, or TYPE65534 for unknown types."},
{Key: "rdata", Label: "Record data", Type: "textarea", Required: true, Width: 8,
Placeholder: `10 1 "https://example.com/"`,
Help: "Rdata exactly as it would appear in a zone file. Unknown types use the RFC 3597 form: \\# 4 0A0B0C0D"},
}},
}
// TypeInfos returns the record type catalogue used by the UI.
func TypeInfos() []TypeInfo { return recordTypes }
// TypeInfoFor looks up one record type's editor definition.
func TypeInfoFor(t string) (TypeInfo, bool) {
t = strings.ToUpper(strings.TrimSpace(t))
for _, ti := range recordTypes {
if ti.Type == t {
return ti, true
}
}
return TypeInfo{}, false
}
// KnownType reports whether the DNS library understands a type name. This
// accepts far more types than have dedicated editors, including the RFC 3597
// TYPEnnnnn form.
func KnownType(t string) bool {
t = strings.ToUpper(strings.TrimSpace(t))
if _, ok := dns.StringToType[t]; ok {
return true
}
if strings.HasPrefix(t, "TYPE") {
if n, err := strconv.Atoi(t[4:]); err == nil && n > 0 && n <= 65535 {
return true
}
}
return false
}
// NormaliseType uppercases and checks a record type name.
func NormaliseType(t string) (string, error) {
t = strings.ToUpper(strings.TrimSpace(t))
if t == "" {
return "", errors.New("record type must not be empty")
}
if t == "RAW" {
return "", errors.New("choose a concrete record type in the advanced editor")
}
if !KnownType(t) {
return "", fmt.Errorf("%q is not a known DNS record type; "+
"use the advanced editor with the TYPEnnnnn form for unassigned types", t)
}
switch t {
case "ANY", "AXFR", "IXFR", "OPT", "TSIG", "TKEY":
return "", fmt.Errorf("%s is a meta record type and cannot be stored in a zone", t)
}
return t, nil
}
// BuildRR assembles and validates a resource record.
//
// The zone origin is handed to the parser so that relative names in rdata (and
// "@") resolve exactly the way they would in a real zone file.
func BuildRR(zone, name, rtype, data string, ttl uint32) (dns.RR, error) {
rtype, err := NormaliseType(rtype)
if err != nil {
return nil, err
}
data = strings.TrimSpace(data)
if data == "" {
return nil, fmt.Errorf("%s record data must not be empty", rtype)
}
if err := preflight(rtype, data); err != nil {
return nil, err
}
owner := name
if owner == "" {
owner = "@"
}
line := fmt.Sprintf("%s %d IN %s %s", owner, ttl, rtype, data)
zp := dns.NewZoneParser(strings.NewReader(line), zone, "record")
zp.SetDefaultTTL(ttl)
rr, ok := zp.Next()
if err := zp.Err(); err != nil {
return nil, fmt.Errorf("invalid %s record data: %s", rtype, cleanParseError(err))
}
if !ok || rr == nil {
return nil, fmt.Errorf("invalid %s record data: %q could not be parsed", rtype, data)
}
if _, more := zp.Next(); more {
return nil, fmt.Errorf("%s record data must be a single record", rtype)
}
return rr, nil
}
// preflight catches the mistakes users actually make, so they get a sentence
// they can act on instead of a parser offset.
func preflight(rtype, data string) error {
switch rtype {
case "A":
addr, err := netip.ParseAddr(strings.TrimSpace(data))
if err != nil || !addr.Is4() {
return fmt.Errorf("an A record needs a valid IPv4 address, for example 192.0.2.10 (got %q)", data)
}
case "AAAA":
addr, err := netip.ParseAddr(strings.TrimSpace(data))
if err != nil || addr.Is4() {
return fmt.Errorf("an AAAA record needs a valid IPv6 address, for example 2001:db8::10 (got %q)", data)
}
case "MX":
f := strings.Fields(data)
if len(f) != 2 {
return fmt.Errorf("an MX record needs a preference and a host name, for example: 10 mail.example.com.")
}
if _, err := strconv.ParseUint(f[0], 10, 16); err != nil {
return fmt.Errorf("the MX preference %q must be a number between 0 and 65535", f[0])
}
case "SRV":
f := strings.Fields(data)
if len(f) != 4 {
return fmt.Errorf("an SRV record needs priority, weight, port and target, " +
"for example: 10 20 5060 sip.example.com.")
}
for i, label := range []string{"priority", "weight", "port"} {
if _, err := strconv.ParseUint(f[i], 10, 16); err != nil {
return fmt.Errorf("the SRV %s %q must be a number between 0 and 65535", label, f[i])
}
}
case "CAA":
f := strings.Fields(data)
if len(f) < 3 {
return fmt.Errorf(`a CAA record needs flags, a tag and a quoted value, ` +
`for example: 0 issue "letsencrypt.org"`)
}
case "CNAME", "PTR", "NS", "DNAME":
if strings.Fields(data) == nil || len(strings.Fields(data)) != 1 {
return fmt.Errorf("a %s record takes exactly one name, for example: host.example.com.", rtype)
}
case "TXT", "SPF":
if !strings.HasPrefix(strings.TrimSpace(data), `"`) {
return fmt.Errorf("%s record data must be quoted; the record editor does this for you", rtype)
}
}
return nil
}
// cleanParseError strips the parser's file/line noise, which is meaningless
// for a single record typed into a form.
func cleanParseError(err error) string {
msg := err.Error()
if i := strings.Index(msg, "dns: "); i >= 0 {
msg = msg[i+len("dns: "):]
}
if i := strings.Index(msg, " at record:"); i >= 0 {
msg = msg[:i]
}
if i := strings.Index(msg, "\" at line"); i >= 0 {
msg = msg[:i+1]
}
return msg
}
// QuoteTXT turns free text into one or more quoted character-strings, splitting
// at the 255 byte limit that a single DNS character-string may hold.
func QuoteTXT(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return `""`
}
// Already-quoted input is passed through so operators can hand-craft
// multi-string records.
if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) && len(s) > 1 {
return s
}
const maxChunk = 255
var chunks []string
for len(s) > maxChunk {
chunks = append(chunks, s[:maxChunk])
s = s[maxChunk:]
}
chunks = append(chunks, s)
for i, c := range chunks {
chunks[i] = `"` + escapeCharString(c) + `"`
}
return strings.Join(chunks, " ")
}
func escapeCharString(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
for _, r := range s {
switch r {
case '"', '\\':
b.WriteByte('\\')
}
b.WriteRune(r)
}
return b.String()
}
// AssembleRData joins editor field values into zone-file rdata, applying
// quoting where the record type requires it.
func AssembleRData(rtype string, values map[string]string) (string, error) {
info, ok := TypeInfoFor(rtype)
if !ok {
return "", fmt.Errorf("no editor is defined for record type %q", rtype)
}
var parts []string
for _, f := range info.Fields {
v := strings.TrimSpace(values[f.Key])
if v == "" {
if f.Required {
return "", fmt.Errorf("%s is required for a %s record", f.Label, rtype)
}
continue
}
if f.Quote {
v = QuoteTXT(v)
}
parts = append(parts, v)
}
if len(parts) == 0 {
return "", fmt.Errorf("%s record data must not be empty", rtype)
}
return strings.Join(parts, " "), nil
}
// SplitRData splits stored rdata back into editor field values so an existing
// record can be edited in its dedicated form.
func SplitRData(rtype, data string) map[string]string {
out := map[string]string{}
info, ok := TypeInfoFor(rtype)
if !ok {
return out
}
// Types whose final field swallows the remainder of the line.
fields := info.Fields
if len(fields) == 0 {
return out
}
// A single quoted field (TXT, SPF) takes the whole rdata verbatim.
if len(fields) == 1 {
v := data
if fields[0].Quote {
v = UnquoteTXT(data)
}
out[fields[0].Key] = v
return out
}
toks := tokeniseRData(data)
for i, f := range fields {
if i >= len(toks) {
break
}
if i == len(fields)-1 && len(toks) > len(fields) {
// Trailing field absorbs everything left (e.g. SVCB parameters).
rest := strings.Join(toks[i:], " ")
if f.Quote {
rest = UnquoteTXT(rest)
}
out[f.Key] = rest
break
}
v := toks[i]
if f.Quote {
v = UnquoteTXT(v)
}
out[f.Key] = v
}
return out
}
// tokeniseRData splits on whitespace while keeping quoted strings together.
func tokeniseRData(s string) []string {
var out []string
var cur strings.Builder
inQuote, escaped, started := false, false, false
flush := func() {
if started {
out = append(out, cur.String())
cur.Reset()
started = false
}
}
for _, r := range s {
switch {
case escaped:
cur.WriteRune(r)
escaped = false
started = true
case r == '\\':
cur.WriteRune(r)
escaped = true
started = true
case r == '"':
cur.WriteRune(r)
inQuote = !inQuote
started = true
case (r == ' ' || r == '\t') && !inQuote:
flush()
default:
cur.WriteRune(r)
started = true
}
}
flush()
return out
}
// UnquoteTXT reverses QuoteTXT, concatenating adjacent character-strings.
func UnquoteTXT(s string) string {
s = strings.TrimSpace(s)
if !strings.Contains(s, `"`) {
return s
}
var b strings.Builder
inQuote, escaped := false, false
for _, r := range s {
switch {
case escaped:
b.WriteRune(r)
escaped = false
case r == '\\' && inQuote:
escaped = true
case r == '"':
inQuote = !inQuote
case inQuote:
b.WriteRune(r)
}
}
return b.String()
}
// CNAMEConflict reports whether adding a record of type newType at a name that
// already holds the given types would create an illegal combination.
//
// RFC 1034 forbids a CNAME from coexisting with any other data at the same
// name, with the DNSSEC types being the standard exception.
func CNAMEConflict(newType string, existing []string) error {
newType = strings.ToUpper(newType)
hasCNAME := false
otherTypes := 0
for _, t := range existing {
t = strings.ToUpper(t)
if t == "CNAME" {
hasCNAME = true
continue
}
if !dnssecCompatible(t) {
otherTypes++
}
}
if newType == "CNAME" {
if hasCNAME {
return errors.New("this name already has a CNAME record; a name may only have one")
}
if otherTypes > 0 {
return errors.New("a CNAME cannot coexist with other records at the same name; " +
"remove the existing records or use a different name")
}
return nil
}
if hasCNAME && !dnssecCompatible(newType) {
return fmt.Errorf("this name already has a CNAME record, so it cannot also have a %s record", newType)
}
return nil
}
// dnssecCompatible reports whether a type is permitted alongside a CNAME.
func dnssecCompatible(t string) bool {
switch strings.ToUpper(t) {
case "RRSIG", "NSEC", "NSEC3", "KEY", "DS":
return true
}
return false
}
// ApexRestricted reports whether a record type is illegal at a zone apex.
func ApexRestricted(rtype string) error {
switch strings.ToUpper(rtype) {
case "CNAME":
return errors.New("a CNAME cannot be placed at the zone apex; " +
"use an A, AAAA or HTTPS record instead")
case "DNAME":
return errors.New("a DNAME cannot be placed at the zone apex")
}
return nil
}