207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
package checker
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/miekg/dns"
|
||
)
|
||
|
||
const dnsTimeout = 5 * time.Second
|
||
|
||
// FallbackResolver is the resolver used when /etc/resolv.conf is missing or
|
||
// empty. It can be overridden at startup (e.g. via a CLI flag) so operators
|
||
// don't silently leak lookups to a third party.
|
||
var FallbackResolver = net.JoinHostPort("1.1.1.1", "53")
|
||
|
||
// dnsExchange sends a single query. proto="" uses UDP and retries over TCP on
|
||
// truncation; recursion controls the RD flag.
|
||
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, recursion bool) (*dns.Msg, error) {
|
||
client := dns.Client{Net: proto, Timeout: dnsTimeout}
|
||
|
||
m := new(dns.Msg)
|
||
m.Id = dns.Id()
|
||
m.Question = []dns.Question{q}
|
||
m.RecursionDesired = recursion
|
||
m.SetEdns0(4096, true)
|
||
|
||
if deadline, ok := ctx.Deadline(); ok {
|
||
if d := time.Until(deadline); d > 0 && d < client.Timeout {
|
||
client.Timeout = d
|
||
}
|
||
}
|
||
|
||
r, _, err := client.Exchange(m, server)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r == nil {
|
||
return nil, fmt.Errorf("nil response from %s", server)
|
||
}
|
||
if r.Truncated && proto == "" {
|
||
tcpClient := dns.Client{Net: "tcp", Timeout: client.Timeout}
|
||
if r2, _, err2 := tcpClient.Exchange(m, server); err2 == nil && r2 != nil {
|
||
return r2, nil
|
||
}
|
||
}
|
||
return r, nil
|
||
}
|
||
|
||
// systemResolver returns the first configured resolver of the local system.
|
||
func systemResolver() string {
|
||
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||
if err != nil || len(cfg.Servers) == 0 {
|
||
return FallbackResolver
|
||
}
|
||
return net.JoinHostPort(cfg.Servers[0], cfg.Port)
|
||
}
|
||
|
||
// hostPort returns "host:port", correctly bracketing IPv6 literals.
|
||
func hostPort(host, port string) string {
|
||
host = strings.TrimSuffix(host, ".")
|
||
return net.JoinHostPort(host, port)
|
||
}
|
||
|
||
// findReverseZone walks up the labels of fqdn until it finds a zone cut
|
||
// (SOA). Returns the apex FQDN and the list of "host:53" authoritative
|
||
// servers. The walk stops at the reverse-arpa apex (in-addr.arpa or
|
||
// ip6.arpa) so we never accept a non-reverse zone (or the root) as a match.
|
||
func findReverseZone(ctx context.Context, fqdn string) (apex string, servers []string, err error) {
|
||
resolver := systemResolver()
|
||
labels := dns.SplitDomainName(fqdn)
|
||
for i := range labels {
|
||
candidate := dns.Fqdn(strings.Join(labels[i:], "."))
|
||
if !isReverseArpa(candidate) {
|
||
break
|
||
}
|
||
q := dns.Question{Name: candidate, Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
|
||
r, rerr := dnsExchange(ctx, "", resolver, q, true)
|
||
if rerr != nil {
|
||
continue
|
||
}
|
||
if r.Rcode != dns.RcodeSuccess {
|
||
continue
|
||
}
|
||
hasSOA := false
|
||
for _, rr := range r.Answer {
|
||
if _, ok := rr.(*dns.SOA); ok {
|
||
hasSOA = true
|
||
break
|
||
}
|
||
}
|
||
if !hasSOA {
|
||
continue
|
||
}
|
||
apex = candidate
|
||
// NS resolution failures are non-fatal: we still located the zone,
|
||
// and queryPTR will fall back to the system resolver. Returning an
|
||
// error here would make reverseZoneRule wrongly report "zone not
|
||
// found".
|
||
servers, _ := resolveZoneNSAddrs(ctx, apex)
|
||
return apex, servers, nil
|
||
}
|
||
return "", nil, fmt.Errorf("could not locate reverse zone of %s", fqdn)
|
||
}
|
||
|
||
// resolveZoneNSAddrs returns "host:53" entries for every NS of the zone.
|
||
func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) {
|
||
var resolver net.Resolver
|
||
nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, "."))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out []string
|
||
for _, ns := range nss {
|
||
addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, "."))
|
||
if err != nil || len(addrs) == 0 {
|
||
continue
|
||
}
|
||
for _, a := range addrs {
|
||
out = append(out, hostPort(a, "53"))
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// queryAtAuth sends q to the first reachable server of the list.
|
||
func queryAtAuth(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) {
|
||
var lastErr error
|
||
for _, s := range servers {
|
||
r, err := dnsExchange(ctx, "", s, q, false)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
return r, s, nil
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = fmt.Errorf("no servers provided")
|
||
}
|
||
return nil, "", lastErr
|
||
}
|
||
|
||
// rcodeText returns the textual name of an rcode or a fallback string.
|
||
func rcodeText(r int) string {
|
||
if s, ok := dns.RcodeToString[r]; ok {
|
||
return s
|
||
}
|
||
return fmt.Sprintf("RCODE(%d)", r)
|
||
}
|
||
|
||
// lowerFQDN returns the canonical lowercase FQDN form of name.
|
||
func lowerFQDN(name string) string {
|
||
return strings.ToLower(dns.Fqdn(name))
|
||
}
|
||
|
||
// reverseNameToIP decodes a reverse-arpa name back to a net.IP. It accepts
|
||
// both in-addr.arpa (IPv4) and ip6.arpa (IPv6). Returns nil if the name is
|
||
// malformed.
|
||
func reverseNameToIP(name string) net.IP {
|
||
n := strings.ToLower(strings.TrimSuffix(dns.Fqdn(name), "."))
|
||
|
||
switch {
|
||
case strings.HasSuffix(n, ".in-addr.arpa"):
|
||
labels := strings.Split(strings.TrimSuffix(n, ".in-addr.arpa"), ".")
|
||
if len(labels) != 4 {
|
||
return nil
|
||
}
|
||
// Reverse: "4.3.2.1" -> "1.2.3.4"
|
||
out := make([]string, 4)
|
||
for i, l := range labels {
|
||
if _, err := strconv.Atoi(l); err != nil {
|
||
return nil
|
||
}
|
||
out[3-i] = l
|
||
}
|
||
return net.ParseIP(strings.Join(out, "."))
|
||
|
||
case strings.HasSuffix(n, ".ip6.arpa"):
|
||
labels := strings.Split(strings.TrimSuffix(n, ".ip6.arpa"), ".")
|
||
if len(labels) != 32 {
|
||
return nil
|
||
}
|
||
// Reverse the nibbles and regroup into 8 × 4-hex blocks.
|
||
var sb strings.Builder
|
||
for i := len(labels) - 1; i >= 0; i-- {
|
||
if len(labels[i]) != 1 {
|
||
return nil
|
||
}
|
||
sb.WriteString(labels[i])
|
||
if i > 0 && (len(labels)-i)%4 == 0 {
|
||
sb.WriteByte(':')
|
||
}
|
||
}
|
||
return net.ParseIP(sb.String())
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// isReverseArpa reports whether name lies inside in-addr.arpa or ip6.arpa.
|
||
func isReverseArpa(name string) bool {
|
||
n := lowerFQDN(name)
|
||
return strings.HasSuffix(n, ".in-addr.arpa.") || strings.HasSuffix(n, ".ip6.arpa.")
|
||
}
|