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:
abrown38andClaude Opus 5.5 committed 2026-10-08 19:02:34 +00:00
1 parent 1afed741bf
commit 293e6ddd33
47 files changed
+3717 -95

No files matched your search

+51 -6
View File
@@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/model"
"jarl/internal/store"
@@ -79,11 +81,12 @@ func (l *rateLimiter) cleanup() {
}
}
// throttle allows an event once a minute per key (for log lines that
// could otherwise repeat on every request).
// throttle allows an event once per interval (every, a minute if unset)
// per key, for log lines that could otherwise repeat on every request.
type throttle struct {
mu sync.Mutex
last map[string]time.Time
every time.Duration
mu sync.Mutex
last map[string]time.Time
}
func (t *throttle) allow(key string, now time.Time) bool {
@@ -92,11 +95,15 @@ func (t *throttle) allow(key string, now time.Time) bool {
if t.last == nil {
t.last = map[string]time.Time{}
}
if now.Sub(t.last[key]) < time.Minute {
every := t.every
if every == 0 {
every = time.Minute
}
if now.Sub(t.last[key]) < every {
return false
}
for k, v := range t.last {
if now.Sub(v) > time.Hour {
if now.Sub(v) > every+time.Hour {
delete(t.last, k)
}
}
@@ -206,12 +213,46 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
s.store.TouchToken(ctx, t.ID, ip)
}
s.warnTokenExpiry(t, ip, now)
if previous && s.oldSecretWarn.allow(fmt.Sprintf("%d %s", t.ID, ip), now) {
s.log.Warn("token used with its old secret; update this client before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
}
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
}
// warnTokenExpiry logs, at most once a day per token, that a token in use
// expires soon (or has expired). ip is where it was used ("" when found by
// the daily check).
func (s *Server) warnTokenExpiry(t *model.Token, ip string, now time.Time) {
if !t.ExpiresWithin(now, model.TokenExpiryWarning) || !s.expiryWarn.allow(strconv.FormatInt(t.ID, 10), now) {
return
}
args := []any{"token_id", t.ID, "token", t.Username + "/" + t.Name, "expires_at", t.ExpiresAt.Format(time.RFC3339)}
if ip != "" {
args = append(args, "ip", ip)
}
msg := "API token expires soon; give it a new lifetime with 'jarl token rotate'"
if now.After(*t.ExpiresAt) {
msg = "API token has expired; anything still using it is refused (rotate it with 'jarl token rotate')"
}
s.log.Warn(msg, args...)
}
// warnExpiringTokens runs warnTokenExpiry for every token (from
// housekeeping), so tokens that are rarely used are reported too. Tokens
// that expired more than TokenExpiryWarning ago are no longer reported.
func (s *Server) warnExpiringTokens(ctx context.Context, now time.Time) {
ts, err := s.store.ListTokens(ctx, 0)
if err != nil {
return
}
for _, t := range ts {
if t.ExpiresAt != nil && now.Sub(*t.ExpiresAt) < model.TokenExpiryWarning {
s.warnTokenExpiry(t, "", now)
}
}
}
// authed wraps a handler with authentication and error handling.
func (s *Server) authed(h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -221,6 +262,10 @@ func (s *Server) authed(h handler) http.HandlerFunc {
s.writeErr(w, r, err, nil)
return
}
if p.Token != nil && p.Token.ExpiresWithin(time.Now(), model.TokenExpiryWarning) {
// The CLI warns its user when it sees this.
w.Header().Set(api.TokenExpiresHeader, p.Token.ExpiresAt.UTC().Format(time.RFC3339))
}
ctx := context.WithValue(r.Context(), keyPrincipal, p)
if se != nil {
ctx = context.WithValue(ctx, keySession, se)