// 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 &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}, nil } // 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 &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}, 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 } 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 uint32 `json:"serial"` HasSerial bool `json:"-"` Nodes int `json:"nodes"` LastLoaded string `json:"last_loaded"` Secure bool `json:"secure"` Dynamic bool `json:"dynamic"` Raw string `json:"raw"` } // 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 "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"` 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 strings.Contains(line, "server is up and running"): st.Running = true } } return st, nil }