// Package nscheck asks other name servers about a zone over DNS: // // - Serials: is every name server of a zone serving the serial this // server serves? For a primary zone those are its secondaries (the NS // records and also-notify targets); for a secondary zone, its // primaries. // - Delegation: does the parent zone (e.g. "lol." for adbraun.lol) // delegate the zone to the name servers, with the glue addresses, that // Jarl has? Only meaningful for zones on the internet, so it only runs // when asked and reports a missing delegation as information, not as a // problem. // // It only sends ordinary non-recursive SOA and NS queries. package nscheck import ( "context" "encoding/hex" "errors" "fmt" "net" "net/netip" "os" "path/filepath" "sort" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/miekg/dns" ) // Key is a TSIG key to sign queries with, for servers that pick the view // (or allow the query) by key. type Key struct { Name string Algorithm string // e.g. hmac-sha256 Secret string // base64 } // Target is a server to ask. type Target struct { Name string // host name (FQDN), "" when only an address is known Addresses []string // known addresses; nil: look the name up Source string // why it is asked: "NS record", "also-notify", "primaries", ... Port string // "" for the default Key *Key } // Result is one server's answer. type Result struct { Name string `json:"name,omitempty"` Address string `json:"address,omitempty"` Port string `json:"port,omitempty"` Source string `json:"source"` // Status is one of: // self this server (not queried) // in_sync serves the same serial // answered serves the zone (no serial here to compare with) // behind, ahead serves an older or newer serial (RFC 1982) // not_authoritative answers, but doesn't serve the zone // refused refuses the query (allow-query, or its view // doesn't have the zone for this server) // unreachable no answer in time // no_route this host has no route to the address (e.g. // IPv6 on a host without it); not a problem // no_address the name has no address // error anything else (see Error) Status string `json:"status"` Serial *uint32 `json:"serial,omitempty"` // Diff is Serial minus the reference serial (negative: behind). Diff int64 `json:"diff,omitempty"` RTTMs int64 `json:"rtt_ms,omitempty"` Error string `json:"error,omitempty"` } // OK reports whether the result is good news. func (r *Result) OK() bool { switch r.Status { case "in_sync", "answered", "self", "no_route": return true } return false } // Checker sends the queries. type Checker struct { // Timeout per query (default 2s; two tries per address). Timeout time.Duration // Port is the DNS port (default 53; tests use another). Port string // Resolver is the recursive resolver used to look up names // (host:port); default: the first nameserver in /etc/resolv.conf. Resolver string // IsLocal reports whether an address belongs to this host (default: // its interface addresses and loopback). IsLocal func(netip.Addr) bool // LocalKey, when set, signs the queries sent to this host's own // addresses (when they are asked): with views, the key that selects // the zone's view, so the answer comes from that view and not from // whichever view matches this host's address. LocalKey *Key // Watch, if set, is told about each DNS query the checks send and // its outcome as it happens (the web UI shows them while it waits). // It is called from several goroutines at once. Watch func(Event) } // Event is a query a check starts or finishes (Checker.Watch). type Event struct { // Kind is "start" or "end" (of query ID). Kind string `json:"kind"` ID int64 `json:"id"` // Query describes what is asked, as a dig command line. Query string `json:"query,omitempty"` // End: Answer sums up the answer ("192.0.2.1, 2001:db8::1", // "NXDOMAIN", "referral to a.nic.lol., …"), or Error says why there // is none. Serials' SOA queries give the server's Result instead. Answer string `json:"answer,omitempty"` Error string `json:"error,omitempty"` RTTMs int64 `json:"rtt_ms,omitempty"` Result *Result `json:"result,omitempty"` } // eventIDs numbers events; 0 is left for the caller's own steps. var eventIDs atomic.Int64 func (c *Checker) startEvent(query string) int64 { if c.Watch == nil { return 0 } id := eventIDs.Add(1) c.Watch(Event{Kind: "start", ID: id, Query: query}) return id } func (c *Checker) endEvent(e Event) { if c.Watch != nil { e.Kind = "end" c.Watch(e) } } // quietKey in a context stops exchangeHostPort from reporting the query: // the caller reports it with more to say (Serials' SOA queries). type quietKey struct{} // digQuery is the dig command line for m sent to hostport. func digQuery(m *dns.Msg, hostport string, key *Key) string { host, port, err := net.SplitHostPort(hostport) if err != nil { host, port = hostport, "" } if len(m.Question) == 0 { return "dig @" + host } q := m.Question[0] s := digLine(host, port, q.Name, dns.TypeToString[q.Qtype], !m.RecursionDesired, key) if o := m.IsEdns0(); o != nil && o.Do() { s += " +dnssec" } return s } // sumAnswer sums up a DNS answer in a few words. func sumAnswer(resp *dns.Msg) string { if resp.Rcode != dns.RcodeSuccess { return dns.RcodeToString[resp.Rcode] } var vals []string for _, rr := range resp.Answer { switch v := rr.(type) { case *dns.A: vals = append(vals, v.A.String()) case *dns.AAAA: vals = append(vals, v.AAAA.String()) case *dns.NS: vals = append(vals, v.Ns) case *dns.SOA: vals = append(vals, "serial "+strconv.FormatUint(uint64(v.Serial), 10)) case *dns.DS: vals = append(vals, "DS "+strconv.Itoa(int(v.KeyTag))) case *dns.RRSIG: default: vals = append(vals, dns.TypeToString[rr.Header().Rrtype]) } } if len(vals) == 0 { var ns []string for _, rr := range resp.Ns { switch v := rr.(type) { case *dns.NS: ns = append(ns, v.Ns) case *dns.SOA: return "no such record" } } if len(ns) > 0 { return "referral to " + shorten(ns) } return "empty answer" } s := shorten(vals) if !resp.Authoritative && !resp.RecursionAvailable { s += " (not authoritative)" } return s } func shorten(vals []string) string { if len(vals) > 3 { return strings.Join(vals[:3], ", ") + " and " + strconv.Itoa(len(vals)-3) + " more" } return strings.Join(vals, ", ") } // digLine is a dig command line for a query (no TSIG secret). func digLine(server, port, name, types string, norec bool, key *Key) string { s := "dig @" + server if port != "" && port != "53" { s += " -p " + port } s += " " + name + " " + types if norec { s += " +norec" } if key != nil { s += " -y " + key.Algorithm + ":" + strings.TrimSuffix(key.Name, ".") + ":…" } return s } func (c *Checker) timeout() time.Duration { if c.Timeout > 0 { return c.Timeout } return 2 * time.Second } func (c *Checker) port() string { if c.Port != "" { return c.Port } return "53" } func (c *Checker) isLocal(a netip.Addr) bool { if c.IsLocal != nil { return c.IsLocal(a) } return IsLocalAddr(a) } // IsLocalAddr reports whether a is a loopback address or one of this // host's interface addresses. func IsLocalAddr(a netip.Addr) bool { if a.IsLoopback() { return true } for _, b := range LocalAddrs() { if b == a.Unmap() { return true } } return false } // LocalAddrs returns this host's interface addresses. Listing them needs a // netlink socket; where that is not allowed (a systemd unit without // AF_NETLINK in RestrictAddressFamilies), they are read from /proc. func LocalAddrs() []netip.Addr { var out []netip.Addr if addrs, err := net.InterfaceAddrs(); err == nil { for _, ia := range addrs { if n, ok := ia.(*net.IPNet); ok { if b, ok := netip.AddrFromSlice(n.IP); ok { out = append(out, b.Unmap()) } } } return out } return procAddrs("/proc/net") } // procAddrs reads the host's addresses from /proc/net: IPv6 from // if_inet6, IPv4 from the local (/32 host LOCAL) entries of fib_trie. func procAddrs(dir string) []netip.Addr { var out []netip.Addr if b, err := os.ReadFile(filepath.Join(dir, "if_inet6")); err == nil { for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line) if len(f) == 0 || len(f[0]) != 32 { continue } raw, err := hex.DecodeString(f[0]) if err != nil { continue } if a, ok := netip.AddrFromSlice(raw); ok { out = append(out, a) } } } if b, err := os.ReadFile(filepath.Join(dir, "fib_trie")); err == nil { seen := map[netip.Addr]bool{} var last netip.Addr for _, line := range strings.Split(string(b), "\n") { t := strings.TrimSpace(line) if strings.HasPrefix(t, "|-- ") { last, _ = netip.ParseAddr(strings.TrimPrefix(t, "|-- ")) } else if strings.HasPrefix(t, "/32 host LOCAL") && last.IsValid() && !seen[last] { seen[last] = true out = append(out, last) } } } return out } // SystemResolver returns the first nameserver in /etc/resolv.conf as // host:port. func SystemResolver() (string, error) { cc, err := dns.ClientConfigFromFile("/etc/resolv.conf") if err != nil { return "", fmt.Errorf("reading /etc/resolv.conf: %w", err) } if len(cc.Servers) == 0 { return "", errors.New("no nameserver in /etc/resolv.conf") } return net.JoinHostPort(cc.Servers[0], cc.Port), nil } func (c *Checker) resolver() (string, error) { if c.Resolver != "" { return c.Resolver, nil } return SystemResolver() } // exchange sends one query to addr (an IP address) with a retry, and // over TCP when the UDP answer is truncated. func (c *Checker) exchange(ctx context.Context, m *dns.Msg, addr string, key *Key) (*dns.Msg, time.Duration, error) { return c.exchangeHostPort(ctx, m, net.JoinHostPort(addr, c.port()), key) } func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (resp *dns.Msg, rtt time.Duration, err error) { if c.Watch != nil && ctx.Value(quietKey{}) == nil { id := c.startEvent(digQuery(m, hostport, key)) defer func() { e := Event{ID: id, RTTMs: rtt.Milliseconds()} if err != nil { e.Error = err.Error() } else { e.Answer = sumAnswer(resp) } c.endEvent(e) }() } cl := &dns.Client{Timeout: c.timeout()} if key != nil { name := dns.Fqdn(key.Name) cl.TsigSecret = map[string]string{name: key.Secret} m.SetTsig(name, dns.Fqdn(key.Algorithm), 300, time.Now().Unix()) } for try := 0; try < 2; try++ { resp, rtt, err = cl.ExchangeContext(ctx, m, hostport) if err == nil || ctx.Err() != nil { break } } if err == nil && resp.Truncated { cl.Net = "tcp" resp, rtt, err = cl.ExchangeContext(ctx, m, hostport) } return resp, rtt, err } // lookup returns a name's IPv4 and IPv6 addresses through the resolver. func (c *Checker) lookup(ctx context.Context, name string) ([]string, error) { res, err := c.resolver() if err != nil { return nil, err } var out []string var firstErr error for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} { m := new(dns.Msg) m.SetQuestion(dns.Fqdn(name), t) resp, _, err := c.exchangeHostPort(ctx, m, res, nil) if err != nil { if firstErr == nil { firstErr = err } continue } for _, rr := range resp.Answer { switch v := rr.(type) { case *dns.A: out = append(out, v.A.String()) case *dns.AAAA: out = append(out, v.AAAA.String()) } } } if len(out) == 0 && firstErr != nil { return nil, firstErr } return out, nil } // soa asks addr for zone's SOA and fills in r (whose Name, Address and // Source are set). func (c *Checker) soa(ctx context.Context, zone string, key *Key, ref *uint32, r *Result) { m := new(dns.Msg) m.SetQuestion(dns.Fqdn(zone), dns.TypeSOA) m.RecursionDesired = false port := r.Port if port == "" { port = c.port() } resp, rtt, err := c.exchangeHostPort(context.WithValue(ctx, quietKey{}, true), m, net.JoinHostPort(r.Address, port), key) if err != nil { var ne net.Error switch { case errors.Is(err, syscall.ENETUNREACH): // Not the server's fault: typically IPv6 on a host without it. r.Status, r.Error = "no_route", "this server has no route to that address" case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded): r.Status, r.Error = "unreachable", "no answer within "+c.timeout().String() default: r.Status, r.Error = "unreachable", err.Error() } return } r.RTTMs = rtt.Milliseconds() switch { case resp.Rcode == dns.RcodeRefused: r.Status = "refused" return case resp.Rcode != dns.RcodeSuccess: r.Status, r.Error = "error", "answered "+dns.RcodeToString[resp.Rcode] return case !resp.Authoritative: r.Status = "not_authoritative" return } for _, rr := range resp.Answer { if s, ok := rr.(*dns.SOA); ok && strings.EqualFold(s.Hdr.Name, dns.Fqdn(zone)) { serial := s.Serial r.Serial = &serial r.Status = "answered" if ref != nil { r.Status = "in_sync" r.Diff = int64(int32(serial - *ref)) switch { case r.Diff < 0: r.Status = "behind" case r.Diff > 0: r.Status = "ahead" } } return } } r.Status, r.Error = "error", "the answer has no SOA record" } // Serials asks each target for zone's SOA serial and compares it with // ref, the serial this server serves (nil when unknown). Targets with // local addresses are this server and aren't asked. Results are sorted by // name and address. func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets []Target) []Result { var mu sync.Mutex var wg sync.WaitGroup var out []Result add := func(r Result) { mu.Lock() out = append(out, r) mu.Unlock() } seen := map[string]bool{} for _, t := range targets { addrs := t.Addresses if addrs == nil && t.Name != "" { found, err := c.lookup(ctx, t.Name) if err != nil || len(found) == 0 { msg := "no A or AAAA record" if err != nil { msg = "looking up its address: " + err.Error() } add(Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg}) continue } addrs = found } for _, a := range addrs { ip, err := netip.ParseAddr(a) if err != nil || seen[ip.String()+"#"+t.Port] { continue } seen[ip.String()+"#"+t.Port] = true r := Result{Name: t.Name, Address: ip.String(), Port: t.Port, Source: t.Source} if c.isLocal(ip) { r.Status = "self" if ref != nil { s := *ref r.Serial = &s } add(r) continue } wg.Add(1) port := r.Port if port == "" { port = c.port() } // Reported here rather than by exchange: the end carries the // server's status. key := t.Key if key == nil && c.LocalKey != nil && IsLocalAddr(ip) { key = c.LocalKey } id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, key)) go func(r Result, key *Key) { defer wg.Done() c.soa(ctx, zone, key, ref, &r) c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs}) add(r) }(r, key) } } wg.Wait() sort.Slice(out, func(i, j int) bool { if out[i].Name != out[j].Name { return out[i].Name < out[j].Name } return out[i].Address < out[j].Address }) return out }