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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user