Files
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

118 lines
4.5 KiB
Go

package server
import (
"context"
"net/http"
"testing"
"time"
"jarl/internal/api"
"jarl/internal/store"
)
// header sends a GET with a token and returns a response header.
func (e *apiEnv) header(path, token, name string) string {
e.t.Helper()
r, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+path, nil)
r.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(r)
if err != nil {
e.t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
e.t.Fatalf("GET %s = %d", path, resp.StatusCode)
}
return resp.Header.Get(name)
}
func TestTokenExpiryWarnings(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/users", e.token, map[string]any{"username": "certbot", "password": "", "grant_strings": []string{"records:write"}}, 201)
var uid int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "certbot" {
uid = int64(u["id"].(float64))
}
}
soon := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "soon", "expires_in": "5d"}, 201)
later := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "later", "expires_in": "90d"}, 201)
revoked := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "gone", "expires_in": "1d"}, 201)
e.do("DELETE", "/tokens/"+itoa(int64(revoked["id"].(float64))), e.token, nil, 204)
// The dashboard lists the expiring token for the administrator...
names := func(token string) []string {
var out []string
for _, t := range e.do("GET", "/status", token, nil, 200)["expiring_tokens"].([]any) {
out = append(out, t.(map[string]any)["name"].(string))
}
return out
}
if got := names(e.token); len(got) != 1 || got[0] != "soon" {
t.Errorf("admin's expiring tokens = %v", got)
}
// ...and for its own user.
if got := names(soon["token"].(string)); len(got) != 1 || got[0] != "soon" {
t.Errorf("certbot's expiring tokens = %v", got)
}
// Requests with the expiring token say so (the CLI warns); others don't.
if h := e.header("/auth/whoami", soon["token"].(string), api.TokenExpiresHeader); h == "" {
t.Error("no expiry header for a token that expires in 5 days")
} else if exp, err := time.Parse(time.RFC3339, h); err != nil || time.Until(exp) < 4*24*time.Hour {
t.Errorf("expiry header %q", h)
}
if h := e.header("/auth/whoami", later["token"].(string), api.TokenExpiresHeader); h != "" {
t.Errorf("expiry header for a 90-day token: %q", h)
}
if h := e.header("/auth/whoami", e.token, api.TokenExpiresHeader); h != "" {
t.Errorf("expiry header for a token without expiry: %q", h)
}
}
func TestBackupStatus(t *testing.T) {
e := newAPIEnvConfig(t, Config{Version: "test", BackupDir: "/var/backups/jarl", BackupWarnAfter: 48 * time.Hour})
backup := func() map[string]any {
b, _ := e.do("GET", "/status", e.token, nil, 200)["backup"].(map[string]any)
return b
}
if b := backup(); b == nil || b["last_at"] != nil || b["stale"] != false || b["dir"] != "/var/backups/jarl" {
t.Errorf("before any backup: %v", b)
}
ctx := context.Background()
now := time.Now()
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-3 * time.Hour), File: "/var/backups/jarl/jarl-x.db", Size: 1234})
if b := backup(); b["last_file"] != "/var/backups/jarl/jarl-x.db" || b["stale"] != false || b["last_error"] != nil {
t.Errorf("fresh backup: %v", b)
}
// A failure newer than the last success is reported; an older one isn't.
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-time.Hour), Error: "disk full"})
if b := backup(); b["last_error"] != "disk full" {
t.Errorf("failed backup: %v", b)
}
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-72 * time.Hour), File: "/var/backups/jarl/jarl-y.db"})
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-80 * time.Hour), Error: "old"})
if b := backup(); b["stale"] != true || b["last_error"] != nil {
t.Errorf("old backup: %v", b)
}
// Only administrators see it.
e.do("POST", "/users", e.token, map[string]any{"username": "viewer", "password": "", "role": "readonly"}, 201)
var uid int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "viewer" {
uid = int64(u["id"].(float64))
}
}
tok := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
if st := e.do("GET", "/status", tok, nil, 200); st["backup"] != nil {
t.Errorf("non-admin sees backups: %v", st["backup"])
}
}
func TestServerHeader(t *testing.T) {
e := newAPIEnv(t)
if h := e.header("/auth/whoami", e.token, "Server"); h != "Jarl/test" {
t.Errorf("Server: %q", h)
}
}