Files
jarl/cmd/jarl/nscheck.go
T
abrown38andClaude Opus 5.5 293e6ddd33 Add backups, token expiry warnings and name server checks
- Daily database backups: jarl-backup.timer runs 'jarld backup', which
  writes jarl-<UTC time>.db into backup.dir, checks it before naming it,
  and prunes by the time in the name while always keeping the newest
  keep_at_least. New backup: section in jarl.yaml; the dashboard and
  'jarl status' show the newest backup and warn when it is stale or
  failed. The Debian package enables the timer.
- 'jarld restore [FILE | latest]': checks the backup, shows it next to
  the current database, keeps the current one aside and records the
  restore. 'jarld serve' holds a database lock, so a restore can't run
  under a live jarld and two jarlds can't share a database.
- Token expiry warnings from 14 days ahead: dashboard, token lists, a
  daily log line, and a CLI warning via the Jarl-Token-Expires header;
  'jarl token list --expiring'.
- 'jarl zone serials' and the zone page's Name servers tab: compare the
  serial on a primary zone's secondaries (NS records, also-notify) or a
  secondary zone's primaries with this server's.
- 'jarl zone delegation': for zones on the internet, check the parent's
  delegation and glue against the zone; an undelegated zone gets a note.
- Server: Jarl/<version> header; CLI exit code 2 for failed checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:02:34 +00:00

270 lines
8.0 KiB
Go

package main
import (
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"jarl/internal/api"
"jarl/internal/nscheck"
)
// checkFailed is returned when a check ran but found problems (exit code
// 2), after its report was printed.
type checkFailed struct{ msg string }
func (e *checkFailed) Error() string { return e.msg }
func zoneSerialsCmd() *cobra.Command {
var view string
cmd := &cobra.Command{
Use: "serials ZONE",
Short: "Compare the zone's serial on its other name servers with this server's",
Long: `Asks the zone's other name servers which serial they serve and compares it
with the one this server serves.
For a primary zone those are the servers in its NS records and its
also-notify targets (its secondaries); for a secondary or mirror zone, its
primaries. Addresses come from Jarl's own A/AAAA records where it has them,
otherwise from a DNS lookup. This server itself isn't asked.
Exits with 2 when a server is behind, doesn't serve the zone, refuses, or
doesn't answer, so it can run from cron or a monitoring system.`,
Example: ` jarl zone serials adbraun.lol
jarl zone serials adbraun.lol --view internal -o json`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
var resp api.SerialsResponse
if err := c.Get(ctx(), path+"/serials", qv, &resp); err != nil {
return err
}
bad := 0
for _, r := range resp.Servers {
if !r.OK() {
bad++
}
}
err = output(resp, func(w io.Writer) {
head := displayName(resp.Zone)
if resp.View != "" {
head += " [view " + resp.View + "]"
}
head += ", " + resp.Type
if resp.Serial != nil {
src := map[string]string{"named": "served by named here", "jarl": "Jarl's; named not asked"}[resp.SerialSource]
head += fmt.Sprintf(", serial %d (%s)", *resp.Serial, src)
}
fmt.Fprintf(w, "%s\n\n", head)
if len(resp.Servers) > 0 {
fmt.Fprintln(w, "SERVER\tADDRESS\tFROM\tSTATUS\tSERIAL\tTIME")
for _, r := range resp.Servers {
serial, rtt := "-", ""
if r.Serial != nil {
serial = fmt.Sprint(*r.Serial)
}
if r.RTTMs > 0 || r.Status == "in_sync" || r.Status == "behind" || r.Status == "ahead" {
rtt = fmt.Sprintf("%d ms", r.RTTMs)
}
addr := r.Address
if r.Port != "" {
addr += " port " + r.Port
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", firstNonEmpty(displayName(r.Name), "-"), firstNonEmpty(addr, "-"), r.Source, nsStatusText(r), serial, rtt)
}
}
flush(w)
if len(resp.Notes) > 0 {
fmt.Println()
}
for _, n := range resp.Notes {
fmt.Printf("note: %s\n", n)
}
})
if err != nil {
return err
}
if bad > 0 {
if bad == 1 {
return &checkFailed{fmt.Sprintf("1 of %d name servers isn't serving the current serial", len(resp.Servers))}
}
return &checkFailed{fmt.Sprintf("%d of %d name servers aren't serving the current serial", bad, len(resp.Servers))}
}
return nil
},
}
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
return cmd
}
func zoneDelegationCmd() *cobra.Command {
var view, resolver string
cmd := &cobra.Command{
Use: "delegation ZONE",
Short: "Check how the parent zone delegates the zone (for zones on the internet)",
Long: `Checks the zone the way the internet sees it: finds the parent zone (for
adbraun.lol, "lol."), asks each of its servers for the zone's delegation,
and compares the name servers and glue addresses it hands out with the
zone's NS and address records in Jarl. Then asks each delegated name server
for the zone's serial.
A zone used only on your own network isn't delegated; that's reported as a
note, not a problem. Nothing runs this check unless you ask for it.
The parent is found through jarld's resolver (/etc/resolv.conf on the
server), or --resolver. Exits with 2 when the check finds an error (such as
a name server that doesn't serve the zone, or missing glue); warnings and
notes exit with 0.`,
Example: ` jarl zone delegation adbraun.lol --view external
jarl zone delegation adbraun.lol --resolver 9.9.9.9`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
if resolver != "" {
qv.Set("resolver", resolver)
}
var resp api.DelegationResponse
if err := c.Get(ctx(), path+"/delegation", qv, &resp); err != nil {
return err
}
errors := 0
for _, f := range resp.Findings {
if f.Level == "error" {
errors++
}
}
err = output(resp, func(w io.Writer) {
rep := resp.DelegationReport
fmt.Fprintf(w, "Zone:\t%s\n", displayName(rep.Zone))
if rep.Parent != "" {
fmt.Fprintf(w, "Parent zone:\t%s (found through %s)\n", rep.Parent, rep.Resolver)
}
// The parent's servers, grouped by what they say (the root
// zone alone has 13).
var order []string
groups := map[string][]string{}
for _, p := range rep.Parents {
var say string
switch p.Status {
case "delegated":
say = "delegate to " + strings.Join(displayNames(p.NS), ", ")
case "not_delegated":
say = "no delegation"
default:
say = p.Status + ": " + p.Error
}
if groups[say] == nil {
order = append(order, say)
}
groups[say] = append(groups[say], displayName(p.Name))
}
for _, say := range order {
who := groups[say][0]
if n := len(groups[say]); n > 1 {
who = fmt.Sprintf("%s and %d more", who, n-1)
}
fmt.Fprintf(w, " %s:\t%s\n", who, say)
}
if rep.Delegated {
fmt.Fprintf(w, "NS at the parent:\t%s\n", strings.Join(displayNames(rep.ParentNS), ", "))
fmt.Fprintf(w, "NS in the zone:\t%s\n", strings.Join(displayNames(rep.JarlNS), ", "))
for _, g := range rep.Glue {
mark := "matches"
if !g.Match {
mark = "zone says " + firstNonEmpty(strings.Join(g.Jarl, ", "), "nothing")
}
fmt.Fprintf(w, "Glue %s:\t%s (%s)\n", displayName(g.Name), firstNonEmpty(strings.Join(g.Parent, ", "), "none"), mark)
}
}
flush(w)
if len(rep.Servers) > 0 {
fmt.Fprintln(w, "\nSERVER\tADDRESS\tSTATUS\tSERIAL")
for _, r := range rep.Servers {
serial := "-"
if r.Serial != nil {
serial = fmt.Sprint(*r.Serial)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", displayName(r.Name), firstNonEmpty(r.Address, "-"), nsStatusText(r), serial)
}
flush(w)
}
fmt.Println()
for _, f := range rep.Findings {
fmt.Printf("%-8s %s\n", f.Level+":", f.Message)
}
for _, n := range resp.Notes {
fmt.Printf("note: %s\n", n)
}
})
if err != nil {
return err
}
if errors > 0 {
return &checkFailed{fmt.Sprintf("the delegation check found %d error(s)", errors)}
}
return nil
},
}
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
cmd.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with, e.g. 9.9.9.9 (default: the server's)")
return cmd
}
// nsStatusText describes a name server's answer in a few words.
func nsStatusText(r nscheck.Result) string {
switch r.Status {
case "self":
return "this server"
case "in_sync":
return "in sync"
case "answered":
return "answers"
case "behind":
return fmt.Sprintf("BEHIND by %d", -r.Diff)
case "ahead":
return fmt.Sprintf("AHEAD by %d", r.Diff)
case "not_authoritative":
return "DOESN'T SERVE THE ZONE"
case "refused":
return "REFUSED"
case "unreachable":
return "NO ANSWER"
case "no_route":
return "no route from here"
case "no_address":
return "NO ADDRESS: " + r.Error
}
return strings.ToUpper(r.Status) + ": " + r.Error
}
func displayName(n string) string {
if n == "" || n == "." {
return n
}
return strings.TrimSuffix(n, ".")
}
func displayNames(ns []string) []string {
out := make([]string, len(ns))
for i, n := range ns {
out[i] = displayName(n)
}
return out
}
func flush(w io.Writer) {
if f, ok := w.(interface{ Flush() error }); ok {
f.Flush()
}
}