Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes), theme preference on the Advanced page, Changes page with the pending diff. Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and pppoe.vlanName; a rename is staged for the next reboot by router-apply. Remote access: an MCP server (POST /mcp) and an OpenAPI REST API (/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit log; tools to read status, settings, leases, logs (get_logs), and to edit and check the saved settings (never applied). Tokens on the API access page. Firewall: a reserved `local` zone (the router itself) as the destination of rules, so the web UI or SSH can be allowed from the WAN. PLAN.md lists what is still left. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
236 lines
7.3 KiB
Go
236 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTierAveragesAndGaps(t *testing.T) {
|
|
s := newStore()
|
|
base := time.Unix(1_800_000_000, 0) // a multiple of every step
|
|
for i := range 12 { // one minute of 5 s samples
|
|
s.Add("x", base.Add(time.Duration(i)*5*time.Second), float64(i))
|
|
}
|
|
// A gap of 10 minutes, then one more sample.
|
|
s.Add("x", base.Add(11*time.Minute), 100)
|
|
|
|
now := base.Add(11 * time.Minute)
|
|
start, step, vals := s.Read("x", 0, now)
|
|
if step != 5*time.Second || len(vals) != 720 {
|
|
t.Fatalf("step %v, %d values", step, len(vals))
|
|
}
|
|
at := func(ts time.Time) float64 { return vals[int(ts.Sub(start)/step)] }
|
|
if at(base) != 0 || at(base.Add(55*time.Second)) != 11 {
|
|
t.Fatalf("5 s values: %v %v", at(base), at(base.Add(55*time.Second)))
|
|
}
|
|
if !math.IsNaN(at(base.Add(5 * time.Minute))) {
|
|
t.Fatal("the gap is not empty")
|
|
}
|
|
if at(now) != 100 || !math.IsNaN(at(base.Add(-5*time.Second))) {
|
|
t.Fatal("latest bucket or before the first sample")
|
|
}
|
|
// The minute tier averages the first minute.
|
|
start, step, vals = s.Read("x", 1, now)
|
|
if v := vals[int(base.Sub(start)/step)]; v != 5.5 {
|
|
t.Fatalf("minute average %v", v)
|
|
}
|
|
|
|
// An hour later the 5 s ring has turned over: the old values are gone.
|
|
later := now.Add(2 * time.Hour)
|
|
s.Add("x", later, 1)
|
|
_, _, vals = s.Read("x", 0, later)
|
|
for i, v := range vals[:len(vals)-1] {
|
|
if !math.IsNaN(v) {
|
|
t.Fatalf("stale value %v at %d", v, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStoreSurvivesSaving(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "m.gob")
|
|
s := newStore()
|
|
now := time.Unix(1_800_000_000, 0)
|
|
s.Add("x", now, 42)
|
|
s.SetConnected(true, now, "198.51.100.7")
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l, err := loadStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, vals := l.Read("x", 0, now)
|
|
connected, since, events := l.Uplink()
|
|
if vals[len(vals)-1] != 42 || !connected || !since.Equal(now) || len(events) != 1 {
|
|
t.Fatalf("loaded %v %v %v %v", vals[len(vals)-1], connected, since, events)
|
|
}
|
|
}
|
|
|
|
const leaseFile = `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
|
|
192.168.10.101,52:54:00:12:34:56,01:52,3600,%d,1,0,0,laptop,0,,0
|
|
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,old,name,0,,0
|
|
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,phone,0,,0
|
|
192.168.10.102,52:54:00:dd:ee:ff,01:52,0,%d,1,0,0,gone,0,,0
|
|
192.168.10.103,52:54:00:dd:ee:00,01:52,3600,%d,1,0,0,expired,0,,0
|
|
`
|
|
|
|
func TestReadLeases(t *testing.T) {
|
|
now := time.Unix(1_800_000_000, 0)
|
|
future, past := now.Unix()+1800, now.Unix()-10
|
|
path := filepath.Join(t.TempDir(), "dhcp4.leases")
|
|
os.WriteFile(path, []byte(fmt.Sprintf(leaseFile, future, future, future, future, past)), 0o600)
|
|
lans := []LANForm{{Name: "main", Address: "192.168.10.1"}}
|
|
got := readLeases(path, lans, now)
|
|
if len(got) != 2 || got[0].Address != "192.168.10.100" || got[0].Hostname != "phone" ||
|
|
got[1].Hostname != "laptop" || got[1].LAN != "main" {
|
|
t.Fatalf("leases: %+v", got)
|
|
}
|
|
}
|
|
|
|
func csrfFrom(t *testing.T, page string) string {
|
|
t.Helper()
|
|
m := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(page)
|
|
if m == nil {
|
|
t.Fatalf("no CSRF token in:\n%s", page)
|
|
}
|
|
return m[1]
|
|
}
|
|
|
|
func TestParsePingTargets(t *testing.T) {
|
|
got, err := parsePingTargets("gateway, 1.1.1.1\n2606:4700:4700::1111 1.1.1.1")
|
|
if err != nil || strings.Join(got, " ") != "gateway 1.1.1.1 2606:4700:4700::1111" {
|
|
t.Fatalf("%v %v", got, err)
|
|
}
|
|
if _, err := parsePingTargets("example.com"); err == nil {
|
|
t.Fatal("host names are not accepted")
|
|
}
|
|
if _, err := parsePingTargets("1.1.1.1 1.1.1.2 1.1.1.3 1.1.1.4 1.1.1.5 1.1.1.6"); err == nil {
|
|
t.Fatal("too many targets accepted")
|
|
}
|
|
}
|
|
|
|
func login(t *testing.T, h http.Handler) *http.Cookie {
|
|
t.Helper()
|
|
rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("login: %d", rec.Code)
|
|
}
|
|
return rec.Result().Cookies()[0]
|
|
}
|
|
|
|
func TestDashboardRenders(t *testing.T) {
|
|
a, _ := newTestApp(t)
|
|
now := time.Now()
|
|
for i := range 20 {
|
|
at := now.Add(time.Duration(i-20) * 5 * time.Second)
|
|
a.metrics.store.Add("if:eth1:rx", at, 1e6)
|
|
a.metrics.store.Add("if:eth1:tx", at, 2e5)
|
|
}
|
|
h := a.routes()
|
|
cookie := login(t, h)
|
|
for _, path := range []string{"/", "/?range=7d", "/dashboard?range=24h", "/advanced"} {
|
|
rec := do(h, "GET", path, nil, cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: %d\n%s", path, rec.Code, rec.Body)
|
|
}
|
|
body := rec.Body.String()
|
|
if path == "/" && (!strings.Contains(body, "Internet (eth1)") || !strings.Contains(body, "8.0 Mbit/s") ||
|
|
!strings.Contains(body, "/static/dashboard.js")) {
|
|
t.Fatalf("overview lacks the traffic chart:\n%s", body)
|
|
}
|
|
if strings.HasPrefix(path, "/dashboard") && strings.Contains(body, "<html") {
|
|
t.Fatal("the fragment is a whole page")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackup(t *testing.T) {
|
|
a, sys := newTestApp(t)
|
|
h := a.routes()
|
|
cookie := login(t, h)
|
|
page := do(h, "GET", "/advanced", nil, cookie).Body.String()
|
|
token := csrfFrom(t, page)
|
|
rec := do(h, "POST", "/backup", url.Values{"csrf": {token}}, cookie)
|
|
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Disposition"), `attachment; filename="router-backup-`) {
|
|
t.Fatalf("backup: %d %v", rec.Code, rec.Header())
|
|
}
|
|
gz, err := gzip.NewReader(rec.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files := map[string]string{}
|
|
tr := tar.NewReader(gz)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, _ := io.ReadAll(tr)
|
|
files[hdr.Name] = string(data)
|
|
}
|
|
settings, _ := os.ReadFile(sys.settingsPath())
|
|
if files["router/config/settings.json"] != string(settings) || !strings.HasPrefix(files["router/webui-password"], "pbkdf2") {
|
|
t.Fatalf("backup content: %v", keys(files))
|
|
}
|
|
if _, ok := files["router/webui/metrics.gob"]; !ok {
|
|
t.Fatalf("no metric history in the backup: %v", keys(files))
|
|
}
|
|
if rec := do(h, "POST", "/backup", url.Values{}, cookie); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("backup without CSRF token: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func keys(m map[string]string) []string {
|
|
var out []string
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestTheme(t *testing.T) {
|
|
a, sys := newTestApp(t)
|
|
h := a.routes()
|
|
cookie := login(t, h)
|
|
token := csrfFrom(t, do(h, "GET", "/advanced", nil, cookie).Body.String())
|
|
has := func(path, want string, ck *http.Cookie) bool {
|
|
return strings.Contains(do(h, "GET", path, nil, ck).Body.String(), want)
|
|
}
|
|
if has("/advanced", "data-theme", cookie) || !has("/login", "<body class=\"bare\"", nil) {
|
|
t.Fatal("default must follow the browser; the login page has no sidebar")
|
|
}
|
|
save := func(theme string) {
|
|
do(h, "POST", "/prefs", url.Values{"csrf": {token}, "theme": {theme}}, cookie)
|
|
}
|
|
save("dark")
|
|
if sys.loadPrefs().Theme != "dark" || !has("/", `data-theme="dark"`, cookie) || !has("/login", `data-theme="dark"`, nil) {
|
|
t.Fatal("dark not applied")
|
|
}
|
|
save("neon")
|
|
if sys.loadPrefs().Theme != "dark" {
|
|
t.Fatal("an unknown theme was stored")
|
|
}
|
|
do(h, "POST", "/prefs", url.Values{"csrf": {token}, "pingTargets": {"1.1.1.1"}}, cookie)
|
|
if sys.loadPrefs().Theme != "dark" {
|
|
t.Fatal("saving the ping targets dropped the theme")
|
|
}
|
|
save("")
|
|
if has("/advanced", "data-theme", cookie) {
|
|
t.Fatal("automatic not restored")
|
|
}
|
|
}
|