// 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" "errors" "fmt" "net" "net/netip" "sort" "strings" "sync" "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 // Watch, if set, is told about each query Serials sends 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 Serials starts or finishes (Checker.Watch). type Event struct { // Kind is "start" or "end" (of query ID). Kind string `json:"kind"` ID int `json:"id"` // Query describes what is asked, as a dig command line. Query string `json:"query,omitempty"` // End: the server's result (SOA queries), or the addresses found or // the error (address lookups). Result *Result `json:"result,omitempty"` Addresses []string `json:"addresses,omitempty"` Error string `json:"error,omitempty"` } // 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 } addrs, err := net.InterfaceAddrs() if err != nil { return false } for _, ia := range addrs { if n, ok := ia.(*net.IPNet); ok { if b, ok := netip.AddrFromSlice(n.IP); ok && b.Unmap() == a.Unmap() { return true } } } return false } // 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) (*dns.Msg, time.Duration, error) { 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()) } var resp *dns.Msg var rtt time.Duration var err error 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(ctx, 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() } var ids int start := func(query string) int { if c.Watch == nil { return 0 } mu.Lock() ids++ id := ids mu.Unlock() c.Watch(Event{Kind: "start", ID: id, Query: query}) return id } end := func(e Event) { if c.Watch != nil { e.Kind = "end" c.Watch(e) } } seen := map[string]bool{} for _, t := range targets { addrs := t.Addresses if addrs == nil && t.Name != "" { res, _ := c.resolver() id := start(digLine(res, "", t.Name, "A, AAAA", false, nil)) 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() } r := Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg} end(Event{ID: id, Error: msg, Result: &r}) add(r) continue } end(Event{ID: id, Addresses: found}) 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() } id := start(digLine(r.Address, port, zone, "SOA", true, t.Key)) go func(r Result, key *Key) { defer wg.Done() c.soa(ctx, zone, key, ref, &r) end(Event{ID: id, Result: &r}) add(r) }(r, t.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 }