Add token expiry, edit notice, log allowlist, local-zone test; merge PLAN.md into SPECS.md

get_logs needs a unit from an allowlist (router-*, kea, unbound, networkd,
nftables, ddclient, acme, sshd, kernel). API tokens are valid for 1, 7, 30
(default) or 90 days, or without limit. The overview and the Changes page
say which token made the unapplied changes.

options.json (router://options) no longer holds the options' source paths:
they differ between builds of a release, so a router rebuilding itself
offline had to build the file and failed (found by the update test).

Web UI: panels flow into columns on wide screens and the width is no longer
capped; on phones the navigation is a menu button; the Advanced page uses
panels; VLAN dropdowns show their chosen names; the duplicate WireGuard
heading is gone.

basic: a subtest for wan -> local rules.

SPECS.md is now the only document (decisions, how each part works, and what
is not done yet); PLAN.md is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-01 19:06:27 +01:00
co-authored by Claude Sonnet 5.5
parent 586274af4e
commit b1fa7b1396
25 changed files with 536 additions and 294 deletions
+60 -4
View File
@@ -8,6 +8,7 @@ import (
"slices"
"strings"
"testing"
"time"
)
// rest sends a request to the REST API with a token.
@@ -99,15 +100,19 @@ func TestLogs(t *testing.T) {
want []string
bad bool
}{
{logQuery{}, []string{"--lines=100"}, false},
{logQuery{Unit: "router-webui"}, []string{"--lines=100"}, false},
{logQuery{}, nil, true},
{logQuery{Unit: "sshd.service"}, []string{"--unit=sshd.service"}, false},
{logQuery{Unit: "systemd-journald"}, nil, true},
{logQuery{Unit: "getty@tty1"}, nil, true},
{logQuery{Unit: "router-pppoe", Lines: 5, Since: "1 hour ago", Priority: "err", Grep: "LCP", Boot: &one},
[]string{"--lines=5", "--unit=router-pppoe", "--since=1 hour ago", "--priority=err", "--grep=LCP", "--boot=0"}, false},
{logQuery{Unit: "kernel"}, []string{"-k"}, false},
{logQuery{Unit: "--vacuum-time=1s"}, nil, true},
{logQuery{Unit: "a b"}, nil, true},
{logQuery{Lines: 5000}, nil, true},
{logQuery{Priority: "loud"}, nil, true},
{logQuery{Since: "$(reboot)"}, nil, true},
{logQuery{Unit: "router-webui", Lines: 5000}, nil, true},
{logQuery{Unit: "router-webui", Priority: "loud"}, nil, true},
{logQuery{Unit: "router-webui", Since: "$(reboot)"}, nil, true},
}
for _, c := range cases {
args, err := journalArgs(c.q)
@@ -178,3 +183,54 @@ func TestOpenAPI(t *testing.T) {
}
}
}
func TestTokenExpiry(t *testing.T) {
a, _ := newTestApp(t)
h := a.routes()
tok, secret, err := a.tokens.CreateFor("short", "read", time.Hour)
if err != nil || tok.Expires.IsZero() {
t.Fatalf("create: %v %+v", err, tok)
}
if rec := rest(h, secret, "GET", "/api/v1/status", "", nil); rec.Code != 200 {
t.Fatalf("valid token: %d", rec.Code)
}
// Move its end into the past.
toks := a.tokens.load()
toks[0].Expires = time.Now().Add(-time.Minute)
if err := a.tokens.save(toks); err != nil {
t.Fatal(err)
}
if rec := rest(h, secret, "GET", "/api/v1/status", "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("expired token: %d", rec.Code)
}
if rec, _ := rpc(h, secret, "ping", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("expired token on MCP: %d", rec.Code)
}
_, forever, _ := a.tokens.Create("long", "read")
if rec := rest(h, forever, "GET", "/api/v1/status", "", nil); rec.Code != 200 {
t.Fatalf("token without expiry: %d", rec.Code)
}
}
func TestTokenEditNote(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
data, _ := os.ReadFile(sys.settingsPath())
os.WriteFile(sys.confirmedPath(), data, 0o600)
_, tok, _ := a.tokens.Create("assistant", "change")
rec := rest(h, tok, "GET", "/api/v1/settings", "", nil)
e := rec.Header().Get("ETag")
if rec := rest(h, tok, "PATCH", "/api/v1/settings", `{"system":{"hostName":"gw"}}`, map[string]string{"If-Match": e}); rec.Code != 200 {
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
}
cur, _ := os.ReadFile(sys.settingsPath())
if note := sys.tokenEditNote(cur); !strings.Contains(note, "assistant") {
t.Fatalf("no note: %q", note)
}
// A later edit by a person makes the note stale.
other := append([]byte(nil), cur...)
other = append(other, '\n', ' ')
if note := sys.tokenEditNote(other); note != "" {
t.Fatalf("stale note: %q", note)
}
}