Files
go-dns/record.go
T
Konrad NeitzelandCursor ad0bb4533e Document the package, exported constants, and remaining helpers.
godoc now covers ActionAdd/ActionDelete, internal helpers, and UDP-then-TCP UPDATE behaviour.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 20:57:02 +02:00

185 lines
5.0 KiB
Go

package godns
import (
"fmt"
"net"
"strings"
"github.com/miekg/dns"
)
// Record is a single resource record with presentation-format rdata.
type Record struct {
Owner string
Class string
Type string
TTL uint32
RData string
}
// RRSet groups records that share owner, class, and type.
type RRSet struct {
Owner string
Class string
Type string
Records []Record
}
// Zone is a complete in-memory zone from AXFR (or assembled records).
type Zone struct {
Origin string
Serial uint32
Records []Record
}
// RRSets groups Zone records by owner/class/type, preserving first-seen order.
func (z Zone) RRSets() []RRSet {
type key struct{ owner, class, typ string }
order := make([]key, 0)
grouped := make(map[key][]Record)
for _, rec := range z.Records {
k := key{rec.Owner, rec.Class, rec.Type}
if _, ok := grouped[k]; !ok {
order = append(order, k)
}
grouped[k] = append(grouped[k], rec)
}
out := make([]RRSet, 0, len(order))
for _, k := range order {
out = append(out, RRSet{Owner: k.owner, Class: k.class, Type: k.typ, Records: grouped[k]})
}
return out
}
// A builds an IPv4 address record.
func A(owner string, ttl uint32, ip net.IP) (Record, error) {
v4 := ip.To4()
if v4 == nil {
return Record{}, fmt.Errorf("A record requires an IPv4 address")
}
return fromRR(&dns.A{Hdr: rrHeader(owner, dns.TypeA, ttl), A: v4})
}
// AAAA builds an IPv6 address record.
func AAAA(owner string, ttl uint32, ip net.IP) (Record, error) {
if ip == nil || ip.To16() == nil || ip.To4() != nil {
return Record{}, fmt.Errorf("AAAA record requires an IPv6 address")
}
return fromRR(&dns.AAAA{Hdr: rrHeader(owner, dns.TypeAAAA, ttl), AAAA: ip.To16()})
}
// MX builds a mail-exchanger record.
func MX(owner string, ttl uint32, pref uint16, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.MX{Hdr: rrHeader(owner, dns.TypeMX, ttl), Preference: pref, Mx: name})
}
// TXT builds a text record.
func TXT(owner string, ttl uint32, text string) (Record, error) {
return fromRR(&dns.TXT{Hdr: rrHeader(owner, dns.TypeTXT, ttl), Txt: []string{text}})
}
// CNAME builds a canonical-name record.
func CNAME(owner string, ttl uint32, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.CNAME{Hdr: rrHeader(owner, dns.TypeCNAME, ttl), Target: name})
}
// NS builds a nameserver record.
func NS(owner string, ttl uint32, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.NS{Hdr: rrHeader(owner, dns.TypeNS, ttl), Ns: name})
}
// SRV builds a service locator record.
func SRV(owner string, ttl uint32, prio, weight, port uint16, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.SRV{
Hdr: rrHeader(owner, dns.TypeSRV, ttl),
Priority: prio,
Weight: weight,
Port: port,
Target: name,
})
}
// PTR builds a pointer record.
func PTR(owner string, ttl uint32, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.PTR{Hdr: rrHeader(owner, dns.TypePTR, ttl), Ptr: name})
}
// rrHeader builds an INET header. Owner ASCII is checked in fromRR.
func rrHeader(owner string, typ uint16, ttl uint32) dns.RR_Header {
name, err := fqdnASCII(owner)
if err != nil {
// fromRR is not called on this path; builders call fqdn via fromRR after Header is set.
// Validate in fromRR instead so the error is returned to the caller.
name = owner
}
return dns.RR_Header{Name: name, Rrtype: typ, Class: dns.ClassINET, Ttl: ttl}
}
// fromRR maps a miekg/dns RR onto the public Record type.
func fromRR(rr dns.RR) (Record, error) {
hdr := rr.Header()
if err := asciiName("owner", strings.TrimSuffix(hdr.Name, ".")); err != nil {
return Record{}, err
}
if hdr.Name == "" || !isASCII(hdr.Name) {
return Record{}, fmt.Errorf("owner must be ASCII, got %q", hdr.Name)
}
return Record{
Owner: dns.Fqdn(hdr.Name),
Class: dns.ClassToString[hdr.Class],
Type: dns.TypeToString[hdr.Rrtype],
TTL: hdr.Ttl,
RData: strings.TrimSpace(strings.TrimPrefix(rr.String(), hdr.String())),
}, nil
}
// recordToRR encodes a Record as a miekg/dns RR for UPDATE.
func recordToRR(rec Record) (dns.RR, error) {
if err := asciiName("owner", rec.Owner); err != nil {
return nil, err
}
rr, err := dns.NewRR(fmt.Sprintf("%s %d %s %s %s", dns.Fqdn(rec.Owner), rec.TTL, rec.Class, rec.Type, rec.RData))
if err != nil {
return nil, fmt.Errorf("encode %s %s: %w", rec.Type, rec.Owner, err)
}
return rr, nil
}
// fqdnASCII validates ASCII and returns a trailing-dot FQDN.
func fqdnASCII(name string) (string, error) {
if err := asciiName("name", name); err != nil {
return "", err
}
return dns.Fqdn(strings.TrimSpace(name)), nil
}
// isASCII reports whether s contains only bytes <= 127.
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] > 127 {
return false
}
}
return true
}