// 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 } 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() } 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) go func(r Result, key *Key) { defer wg.Done() c.soa(ctx, zone, key, ref, &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 }