// Package bind runs BIND's own tools: named-checkconf and named-checkzone // to validate generated configuration, and rndc to reload named and query // its state. package bind import ( "bytes" "context" "errors" "fmt" "os/exec" "regexp" "strconv" "strings" "time" ) // Tools locates and runs the BIND utilities. type Tools struct { NamedCheckConf string // path to named-checkconf NamedCheckZone string // path to named-checkzone RNDC string // path to rndc RNDCArgs []string // extra rndc arguments, e.g. -k /etc/bind/rndc.key -s 127.0.0.1 -p 953 Env []string // extra environment variables Timeout time.Duration } // Message is one line of tool output. type Message struct { File string `json:"file,omitempty"` Line int `json:"line,omitempty"` Zone string `json:"zone,omitempty"` // zone name, when the message is about a zone View string `json:"view,omitempty"` Text string `json:"text"` Error bool `json:"error"` } // Result is the outcome of a check. type Result struct { OK bool `json:"ok"` Messages []Message `json:"messages"` Output string `json:"output"` } func (t *Tools) timeout() time.Duration { if t.Timeout > 0 { return t.Timeout } return 60 * time.Second } func (t *Tools) run(ctx context.Context, bin string, args ...string) (string, int, error) { ctx, cancel := context.WithTimeout(ctx, t.timeout()) defer cancel() cmd := exec.CommandContext(ctx, bin, args...) if len(t.Env) > 0 { cmd.Env = append(cmd.Environ(), t.Env...) } var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out err := cmd.Run() code := 0 if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { code = ee.ExitCode() err = nil } else { return out.String(), -1, fmt.Errorf("running %s: %w", bin, err) } } if ctx.Err() != nil { return out.String(), -1, fmt.Errorf("%s timed out after %s", bin, t.timeout()) } return out.String(), code, nil } var ( // /path/file:12: message reFileLine = regexp.MustCompile(`^(?:dns_master_load: |dns_rdata_fromtext: )?(/[^:]+|[^:\s]+\.[A-Za-z0-9]+):(\d+): (.*)$`) // zone example.com/IN: message or zone example.com/IN/internal: message reZoneMsg = regexp.MustCompile(`^zone ([^/\s]+)/([A-Z]+)(?:/(\S+))?: (.*)$`) // _default/example.com/IN: message (checkconf summary) reViewZone = regexp.MustCompile(`^([^/\s]+)/([^/\s]+)/([A-Z]+): (.*)$`) ) func isErrorText(s string) bool { l := strings.ToLower(s) switch { case strings.HasPrefix(l, "warning"), strings.Contains(l, ": warning:"): return false case strings.HasPrefix(l, "loaded serial"), l == "ok": return false } return true } // ParseOutput splits tool output into messages. func ParseOutput(out string) []Message { var msgs []Message for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } m := Message{Text: line} if sm := reFileLine.FindStringSubmatch(line); sm != nil { m.File = sm[1] m.Line, _ = strconv.Atoi(sm[2]) m.Text = sm[3] m.Error = isErrorText(sm[3]) } else if sm := reZoneMsg.FindStringSubmatch(line); sm != nil { m.Zone, m.View, m.Text = sm[1], sm[3], sm[4] m.Error = isErrorText(sm[4]) && !strings.HasPrefix(sm[4], "loaded serial") if strings.Contains(sm[4], "is a CNAME (illegal)") || strings.Contains(sm[4], "has no address records") || strings.Contains(sm[4], "has no REQUIRED GLUE") { // named loads the zone anyway; Jarl's own checks // report these with more context. m.Error = strings.Contains(sm[4], "NS '") && !strings.Contains(sm[4], "/NS '") } } else if sm := reViewZone.FindStringSubmatch(line); sm != nil { m.View, m.Zone, m.Text = sm[1], sm[2], sm[4] m.Error = true } else { m.Error = isErrorText(line) && line != "OK" } msgs = append(msgs, m) } return msgs } // CheckConfig runs named-checkconf on path. With loadZones, primary zones // are test-loaded too (-z). func (t *Tools) CheckConfig(ctx context.Context, path string, loadZones bool) (*Result, error) { args := []string{} if loadZones { args = append(args, "-z") } args = append(args, path) out, code, err := t.run(ctx, t.NamedCheckConf, args...) if err != nil { return nil, err } return newResult(out, code), nil } // newResult builds a checker result. The exit code is authoritative: when // the checker succeeded, whatever it printed is a warning, even if the text // doesn't say so (e.g. 'both "recursion no;" and "allow-recursion" active'). func newResult(out string, code int) *Result { r := &Result{OK: code == 0, Messages: ParseOutput(out), Output: out} if r.OK { for i := range r.Messages { r.Messages[i].Error = false } } return r } // CheckZone runs named-checkzone on a zone file with the same strictness // named applies to primary zones (check-names fail, local integrity // checks). func (t *Tools) CheckZone(ctx context.Context, zone, file string) (*Result, error) { out, code, err := t.run(ctx, t.NamedCheckZone, "-k", "fail", "-i", "local", zone, file) if err != nil { return nil, err } return newResult(out, code), nil } // DumpZone loads a zone file and prints it in canonical form (used by the // importer for files with $INCLUDE/$GENERATE). func (t *Tools) DumpZone(ctx context.Context, zone, file string) (string, error) { out, code, err := t.run(ctx, t.NamedCheckZone, "-q", "-D", "-s", "full", "-o", "-", zone, file) if err != nil { return "", err } if code != 0 { return "", fmt.Errorf("named-checkzone failed for %s", zone) } return out, nil } // Version returns the version printed by named-checkconf -v. func (t *Tools) Version(ctx context.Context) (string, error) { out, _, err := t.run(ctx, t.NamedCheckConf, "-v") return strings.TrimSpace(out), err } // RNDCError is returned when rndc reports a failure. type RNDCError struct { Command string Output string } func (e *RNDCError) Error() string { return fmt.Sprintf("rndc %s failed: %s", e.Command, strings.TrimSpace(e.Output)) } // Run runs an rndc command. func (t *Tools) RNDCRun(ctx context.Context, args ...string) (string, error) { full := append(append([]string{}, t.RNDCArgs...), args...) out, code, err := t.run(ctx, t.RNDC, full...) if err != nil { return out, err } if code != 0 { return out, &RNDCError{Command: strings.Join(args, " "), Output: out} } return out, nil } // Reconfig makes named reread its configuration and load new zones. func (t *Tools) Reconfig(ctx context.Context) error { _, err := t.RNDCRun(ctx, "reconfig") return err } // ReloadZone asks named to reload one zone. named queues the reload and // does not report load errors; check the result with ZoneStatus. func (t *Tools) ReloadZone(ctx context.Context, zone, view string) error { _, err := t.RNDCRun(ctx, zoneArgs("reload", zone, view)...) return err } // CheckDS tells named that the parent zone has published (or withdrawn) // the DS record for key tag of a zone it signs (rndc dnssec -checkds). func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg uint8, published bool) (string, error) { which := "withdrawn" if published { which = "published" } z := zoneArgs("", zone, view)[1:] args := append([]string{"dnssec", "-checkds", "-key", strconv.Itoa(int(tag)), "-alg", strconv.Itoa(int(alg)), which}, z...) return t.RNDCRun(ctx, args...) } // ZoneCommand is the rndc command line for a zone command, as shown to // users ("rndc zonestatus example.org IN internal"). func ZoneCommand(cmd, zone, view string) string { return "rndc " + strings.Join(zoneArgs(cmd, zone, view), " ") } func zoneArgs(cmd, zone, view string) []string { z := strings.TrimSuffix(zone, ".") if z == "" { z = "." } if view == "" { return []string{cmd, z} } return []string{cmd, z, "IN", view} } // ZoneStatus is the result of rndc zonestatus. type ZoneStatus struct { Name string `json:"name"` Type string `json:"type"` Files string `json:"files"` // Serial is the serial of the zone file named loaded. For a zone // named signs (inline signing) that is the unsigned zone Jarl wrote; // the signed zone that named serves has its own serial, SignedSerial. Serial uint32 `json:"serial"` HasSerial bool `json:"-"` SignedSerial uint32 `json:"signed_serial,omitempty"` HasSignedSerial bool `json:"-"` Nodes int `json:"nodes"` LastLoaded string `json:"last_loaded"` Secure bool `json:"secure"` InlineSigning bool `json:"inline_signing"` NextKeyEvent string `json:"next_key_event,omitempty"` Dynamic bool `json:"dynamic"` Raw string `json:"raw"` } // ServedSerial is the serial named answers queries with: the signed // zone's for a zone it signs, otherwise the loaded one. func (zs *ZoneStatus) ServedSerial() (uint32, bool) { if zs.HasSignedSerial { return zs.SignedSerial, true } return zs.Serial, zs.HasSerial } // ZoneStatus returns named's view of a zone. func (t *Tools) ZoneStatus(ctx context.Context, zone, view string) (*ZoneStatus, error) { out, err := t.RNDCRun(ctx, zoneArgs("zonestatus", zone, view)...) if err != nil { return nil, err } return ParseZoneStatus(out), nil } // ParseZoneStatus parses rndc zonestatus output. func ParseZoneStatus(out string) *ZoneStatus { zs := &ZoneStatus{Raw: out} for _, line := range strings.Split(out, "\n") { k, v, ok := strings.Cut(line, ": ") if !ok { continue } v = strings.TrimSpace(v) switch strings.TrimSpace(k) { case "name": zs.Name = v case "type": zs.Type = v case "files": zs.Files = v case "serial": if n, err := strconv.ParseUint(v, 10, 32); err == nil { zs.Serial, zs.HasSerial = uint32(n), true } case "signed serial": if n, err := strconv.ParseUint(v, 10, 32); err == nil { zs.SignedSerial, zs.HasSignedSerial = uint32(n), true } case "inline signing": zs.InlineSigning = v == "yes" case "next key event": zs.NextKeyEvent = v case "nodes": zs.Nodes, _ = strconv.Atoi(v) case "last loaded": zs.LastLoaded = v case "secure": zs.Secure = v == "yes" case "dynamic": zs.Dynamic = v == "yes" } } return zs } // ServerStatus is the parsed output of rndc status. type ServerStatus struct { Version string `json:"version"` BootTime string `json:"boot_time"` LastConfigured string `json:"last_configured"` ConfigFile string `json:"config_file"` Zones string `json:"zones"` Running bool `json:"running"` // RecursiveClients is "now/soft limit/hard limit" (the soft limit is // where named starts dropping the oldest queries), TCPClients // "now/limit". RecursiveClients string `json:"recursive_clients,omitempty"` TCPClients string `json:"tcp_clients,omitempty"` CPUs string `json:"cpus,omitempty"` WorkerThreads string `json:"worker_threads,omitempty"` Raw string `json:"raw"` } // Status returns named's status. func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) { out, err := t.RNDCRun(ctx, "status") if err != nil { return nil, err } st := &ServerStatus{Raw: out} for _, line := range strings.Split(out, "\n") { k, v, ok := strings.Cut(line, ": ") switch { case ok && k == "version": st.Version = v case ok && k == "boot time": st.BootTime = v case ok && k == "last configured": st.LastConfigured = v case ok && k == "configuration file": st.ConfigFile = v case ok && k == "number of zones": st.Zones = v case ok && k == "recursive clients": st.RecursiveClients = v case ok && k == "tcp clients": st.TCPClients = v case ok && k == "CPUs found": st.CPUs = v case ok && k == "worker threads": st.WorkerThreads = v case strings.Contains(line, "server is up and running"): st.Running = true } } return st, nil }