Files
go_goutils/netx/dnsx/funcs.go
brent saner c6fc692f5e checking in some WIP
* added some netx funcs
* added netx/dnsx
* currently updating docs and adding *x funcs to sprigx
2026-02-24 17:41:57 -05:00

572 lines
12 KiB
Go

package dnsx
import (
`bytes`
`encoding/base32`
`fmt`
`math`
`net`
`net/netip`
`strings`
`go4.org/netipx`
`r00t2.io/goutils/stringsx`
)
/*
AddrFromPtr returns a [net/netip.Addr] from a PTR record.
It is the inverse of [AddrToPtr].
See also [IpFromPtr].
*/
func AddrFromPtr(s string) (ip netip.Addr, err error) {
var idx int
var ipStr string
var tmpStr string
var spl []string = strings.Split(strings.TrimSuffix(s, "."), ".")
switch len(spl) {
case 6:
if strings.Join(spl[4:], ".") != "in-addr.arpa" {
err = ErrBadPtrRoot
return
}
ipStr = fmt.Sprintf("%s.%s.%s.%s", spl[3], spl[2], spl[1], spl[0])
case 34:
if strings.Join(spl[32:], ".") != "ip6.arpa" {
err = ErrBadPtrRoot
return
}
tmpStr = stringsx.Reverse(strings.ReplaceAll(strings.Join(spl[:32], ""), ".", ""))
for idx = 0; idx < len(tmpStr); idx++ {
if idx%4 == 0 && idx != 0 {
ipStr += ":"
}
ipStr += string(rune(tmpStr[idx]))
}
default:
err = ErrBadPtrLen
return
}
if ip, err = netip.ParseAddr(ipStr); err != nil {
return
}
return
}
/*
AddrToPtr returns a PTR record from ip.
It is the inverse of [AddrFromPtr].
It includes the root label at the end (the trailing period).
*/
func AddrToPtr(ip netip.Addr) (s string) {
var idx int
var b []byte
var ipStr string
if ip.Is6() {
ipStr = stringsx.Reverse(strings.ReplaceAll(ip.StringExpanded(), ":", ""))
ipStr = strings.Join(
strings.Split(ipStr, ""),
".",
)
s = fmt.Sprintf("%s.ip6.arpa.", ipStr)
} else {
b = make([]byte, 4)
copy(b, ip.AsSlice())
for idx = len(b) - 1; idx >= 0; idx-- {
ipStr += fmt.Sprintf("%d.", b[idx])
}
s = fmt.Sprintf("%s.in-addr.arpa.", ipStr)
}
return
}
/*
DnsStrToWire returns a wire-format of a DNS name.
No validation or conversion (other than to wire format) is performed,
and it is expected that any IDN(A)/Punycode translation has
*already been performed* such that recordNm is in the ASCII form.
(See [IsFqdn] for more information on IDN(A)/Punycode.)
For encoding reasons, if any given label/segment has a length of 0 or greater than 255 ([math.MaxUint8]),
[ErrBadLabelLen] will be returned.
See [DnsWireToStr] for the inverse.
*/
func DnsStrToWire(recordNm string) (recordNmBytes []byte, err error) {
var cLen int
var c []byte
var cStr string
var spl []string
var buf *bytes.Buffer = new(bytes.Buffer)
spl = strings.Split(strings.TrimSuffix(recordNm, "."), ".")
for _, cStr = range spl {
c = []byte(cStr)
cLen = len(c)
if !(cLen > 0 && cLen <= math.MaxUint8) {
err = ErrBadLabelLen
return
}
buf.Write(append([]byte{uint8(cLen)}, c...))
}
recordNmBytes = append(buf.Bytes(), 0x00)
return
}
/*
DnsWireToStr is the inverse of [DnsStrToWire]. A trailing . is not included.
For decoding reasons, it will exit with [ErrBadLabelLen] if recordNmBytes is nil/empty or
if no terminating nullbyte is found after 256 label characters have been encountered.
*/
func DnsWireToStr(recordNmBytes []byte) (recordNm string, err error) {
var c []byte
var cLen uint8
var arrLen int
var numChars int
var labels []string
var buf *bytes.Buffer
if recordNmBytes == nil || len(recordNmBytes) == 0 {
err = ErrBadLabelLen
return
}
buf = bytes.NewBuffer(recordNmBytes)
labels = make([]string, 0)
arrLen = len(recordNmBytes)
for {
if cLen, err = buf.ReadByte(); err != nil {
return
}
if cLen == 0x00 {
break
}
numChars += int(cLen)
if numChars > 255 {
err = ErrBadLabelLen
return
}
if numChars > arrLen {
err = ErrBadLabelLen
return
}
c = buf.Next(int(cLen))
labels = append(labels, string(c))
}
recordNm = strings.Join(labels, ".")
return
}
/*
IpFromPtr is like [AddrFromPtr] but with a [net.IP] instead.
It is the inverse of [IpToPtr].
*/
func IpFromPtr(s string) (ip net.IP, err error) {
var a netip.Addr
if a, err = AddrFromPtr(s); err != nil {
return
}
ip = net.IP(a.AsSlice())
return
}
/*
IpToPtr is like [AddrToPtr] but with a [net.IP] instead.
It is the inverse of [IpFromPtr].
*/
func IpToPtr(ip net.IP) (s string) {
var a netip.Addr
a, _ = netipx.FromStdIP(ip)
s = AddrToPtr(a)
return
}
/*
IsFqdn returns a boolean indicating if s is an FQDN that strictly adheres to RFC format requirements.
It performs no lookups/resolution attempts or network operations otherwise.
It will return true for the "apex record" (e.g. the "naked domain"), as this is a valid assignable FQDN.
It will return false for wildcard records (see [IsFqdnWildcard]).
s may or may not end in a period (the root zone; "absolute" FQDNs) (0x00 in wire format).
# TLDs
Because valid TLDs are fairly dynamic and can change frequently,
validation is *not* performed against the TLD itself.
This only ensures that s has a TLD label conforming to the character rules in the referenced RFCs.
See [golang.org/x/net/publicsuffix] if precise TLD validation is required (though true TLD validation generally
requires fetching the current TLD lists from IANA at runtime like [github.com/bombsimon/tld-validator]).
# Special RFC-Defined Accommodations
RFC 2181 [§ 11] specifies that site-local DNS software may accommodate non-RFC-conforming rules.
This function may and likely will return false for these site-local deviations.
The Lookup* functions/mthods in [net] should be used to validate in these casts
if that accommodation is necessary.
Note that underscores are not valid for "true" FQDNs as they are only valid for e.g. SRV record names,
TXT records, etc. - not A/AAAA/CNAME, etc. - see RFC 8553 for details.
See the following functions for allowing additional syntax/rule validation
that have record-type-specific accommodations made:
* [IsFqdnDefinedTxt]
* [IsFqdnNsec3]
* [IsFqdnSrv]
* [IsFqdnWildcard]
# RFC Coverage
This function should conform properly to:
* RFC 952
* RFC 1034 and RFC 1035
* RFC 1123
* RFC 2181 (selectively, see above)
preferring the most up-to-date rules where relevant (e.g. labels may start with digits, as per RFC 1123).
It enforces/checks label and overall length limits as defined by RFC.
# IDN(A) and Punycode
Note that it expects the ASCII-only/presentation form of a record name and
will not perform any IDN/IDNA nor Punycode translation.
If a caller anticipates FQDNs in their localized format,
the caller must perform translation first
(via e.g. [gitlab.com/golang-commonmark/puny], [golang.org/x/net/idna], etc.).
To reiterate, IDN/IDNA:
* RFC 3490
* RFC 5890
* RFC 5891
* RFC 5892
* RFC 5893
* RFC 5894
and Punycode (RFC 3492) *MUST* use their ASCII forms, NOT the localized/Unicode formats.
[§ 11]: https://datatracker.ietf.org/doc/html/rfc2181#section-11
*/
func IsFqdn(s string) (fqdn bool) {
var lbl string
var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".")
if !commonFqdn(s) {
return
}
for _, lbl = range lbls {
if !IsLabel(lbl) {
return
}
}
fqdn = true
return
}
/*
IsFqdnDefinedTxt is like [IsFqdn] but explicitly *only* allows fully-qualified
RFC-defined TXT "subtypes":
* ACME DNS-01 (RFC 8555)
* BIMI (RFC draft [bimi])
* DKIM (RFC 6376, RFC 8301, RFC 8463)
* DKIM ATPS (RFC 6541)
* DMARC (RFC 7489, RFC 9091, RFC 9616)
* MTA-STS (RFC 8461)
* TLSRPT (RFC 8460)
Note that the following TXT "subtypes" do not have special formatting in labels/name,
and thus are not covered by this function:
* SPF (RFC 4408, RFC 7208)
[bimi]: https://datatracker.ietf.org/doc/html/draft-brand-indicators-for-message-identification
*/
func IsFqdnDefinedTxt(fqdn string) (isOk bool) {
var lbls []string = strings.Split(fqdn, ".")
if lbls == nil || len(lbls) < 2 {
return
}
switch lbls[0] {
case "_dmarc", "_mta-sts", "_acme-challenge":
if len(lbls) < 2 {
return
}
isOk = IsFqdn(strings.Join(lbls[1:], "."))
case "_smtp":
if len(lbls) <= 2 {
return
}
if lbls[1] != "_tls" {
return
}
isOk = IsFqdn(strings.Join(lbls[2:], "."))
default:
if !IsLabel(lbls[0]) {
return
}
switch lbls[1] {
case "_domainkey", "_atps", "_bimi":
isOk = IsFqdn(strings.Join(lbls[2:], "."))
}
}
// TODO
return
}
/*
IsFqdnNsec3 confirms (partially) that s is a valid NSEC3 record name.
Note that due to the record name being a base32 encoding of a *hash*, the validity
can't be 100% confirmed with certainty - only basic checks can be done.
NSEC3 can be found via:
* RFC 5155
* RFC 6840
* RFC 6944
* RFC 7129
* RFC 8198
* RFC 9077
* RFC 9157
* RFC 9276
* RFC 9905
At the time of writing, only one hashing algorithm (SHA-1) has been specified.
However, because this function does not check against the IANA registration at runtime,
it's possible that this changes but the library may not immediately reflect this.
*/
func IsFqdnNsec3(s string) (maybeNsec3 bool) {
var h []byte
var err error
var isAscii bool
var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".")
if !commonFqdn(s) {
return
}
if len(lbls) <= 2 {
return
}
if len(lbls[0]) != 32 { // SHA1 is 160 bits/20 bytes digest, which is always 32 chars in base32(hex)
return
}
if h, err = base32.StdEncoding.DecodeString(strings.ToUpper(lbls[0])); err != nil {
return
}
if isAscii, err = stringsx.IsAsciiSpecial(
strings.ToLower(lbls[0]),
false, false, false, false,
[]byte{
// Normally, Base32 goes A-Z, 2-7
// but NSEC3 uses Base32Hex (RFC 4648 § 7),
// which is 0-9A-V
'0', '1', '2', '3', '4', '5',
'6', '7', '8', '9', 'a', 'b',
'c', 'd', 'e', 'f', 'g', 'h',
'j', 'k', 'm', 'n', 'p', 'q',
'r', 's', 't', 'u', 'v',
},
nil,
); err != nil {
return
}
if isAscii {
return
}
if len(h) != 20 {
return
}
maybeNsec3 = IsFqdn(strings.Join(lbls[1:], "."))
return
}
/*
IsFqdnSrv is like [IsFqdn], but explicitly *only* allows fully-qualified SRV records
(i.e. underscores must start the first two labels, and there must be at least two additional
labels after these labels).
Note that the protocol is not checked for validity, as that would require runtime
validation against a resource liable to change and would need to be fetched dynamically - see
the [IANA Protocol Numbers registry].
[IANA Protocol Numbers registry]: https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml
*/
func IsFqdnSrv(s string) (srv bool) {
var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".")
if !commonFqdn(s) {
return
}
if len(lbls) <= 4 {
return
}
if !strings.HasPrefix(lbls[0], "_") {
return
}
if !strings.HasPrefix(lbls[1], "_") {
return
}
srv = IsFqdn(strings.Join(lbls[2:], "."))
return
}
// IsFqdnWildcard is like [IsFqdn] but explicitly *only* allows fully-qualified wildcard records.
func IsFqdnWildcard(s string) (wildcard bool) {
var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".")
if len(lbls) < 2 {
return
}
if lbls[0] != "*" {
return
}
wildcard = IsFqdn(strings.Join(lbls[1:], "."))
return
}
// IsLabel returns true if s is a valid DNS label for standard records.
func IsLabel(s string) (isLbl bool) {
var err error
if strings.HasPrefix(s, "-") {
return
}
if strings.HasSuffix(s, "-") {
return
}
if isLbl, err = stringsx.IsAsciiSpecial(
s,
false, false, false, false,
[]byte{
'a', 'b', 'c', 'd', 'e', 'f',
'g', 'h', 'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p', 'q', 'r',
's', 't', 'u', 'v', 'w', 'x',
'y', 'z', '0', '1', '2', '3',
'4', '5', '6', '7', '8', '9',
'-',
},
nil,
); err != nil {
return
}
return
}
/*
IsPtr returns true if s is a PTR (also called an "rDNS" or "reverse DNS" record) name.
If true, the IP is returned as well (otherwise it will be nil).
*/
func IsPtr(s string) (isPtr bool, addr net.IP) {
var err error
var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".")
if len(lbls) < 6 {
return
}
if _, err = AddrFromPtr(s); err != nil {
return
}
isPtr = true
return
}
// commonFqdn is used to validate some rules common to all record names.
func commonFqdn(s string) (isOk bool) {
var err error
var lbl string
var isAscii bool
var labels []string
var domstr string = strings.ToLower(strings.TrimSuffix(s, "."))
if isAscii, err = stringsx.IsAsciiSpecial(
domstr, false, false, false, false,
[]byte{
'a', 'b', 'c', 'd', 'e', 'f',
'g', 'h', 'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p', 'q', 'r',
's', 't', 'u', 'v', 'w', 'x',
'y', 'z', '0', '1', '2', '3',
'4', '5', '6', '7', '8', '9',
'-', '_', '.',
},
nil,
); err != nil {
return
}
if !isAscii {
return
}
if (len(domstr) + 1) > 255 { // +1 for root label
return
}
labels = strings.Split(domstr, ".")
for _, lbl = range labels {
if len(lbl) < 1 || len(lbl) > 63 {
return
}
}
// TODO?
isOk = true
return
}