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>
This commit is contained in:
1 parent
1afed741bf
commit
293e6ddd33
47 files changed
+3717
-95
No files matched your search
@@ -0,0 +1,269 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user