Add the appliance web UI look, interface names, MCP and REST API, local zone
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
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rest sends a request to the REST API with a token.
|
||||
func rest(h http.Handler, token, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRESTAuthAndScopes(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
if rec := rest(h, "", "GET", "/api/v1/status", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no token: %d", rec.Code)
|
||||
}
|
||||
_, read, _ := a.tokens.Create("reader", "read")
|
||||
_, change, _ := a.tokens.Create("changer", "change")
|
||||
if rec := rest(h, read, "GET", "/api/v1/status", "", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"uplink"`) {
|
||||
t.Fatalf("status: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, read, "DELETE", "/api/v1/changes", "", nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("a read token discards: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, read, "PATCH", "/api/v1/settings", `{}`, map[string]string{"If-Match": "x"}); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("a read token patches: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, change, "DELETE", "/api/v1/changes", "", nil); rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized {
|
||||
t.Fatalf("discard: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, read, "POST", "/api/v1/status", "", nil); rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("POST: %d", rec.Code)
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/api/v1/status", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+read)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign origin: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTSettings(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
|
||||
os.WriteFile(sys.confirmedPath(), data, 0o600)
|
||||
}
|
||||
h := a.routes()
|
||||
_, tok, _ := a.tokens.Create("changer", "change")
|
||||
rec := rest(h, tok, "GET", "/api/v1/settings", "", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("get: %d", rec.Code)
|
||||
}
|
||||
etag := rec.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("no ETag header")
|
||||
}
|
||||
patch := `{"system":{"hostName":"gw"}}`
|
||||
if rec := rest(h, tok, "PATCH", "/api/v1/settings", patch, nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("without If-Match: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, tok, "PATCH", "/api/v1/settings", patch, map[string]string{"If-Match": `"0000"`}); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("stale ETag: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := rest(h, tok, "PATCH", "/api/v1/settings", `{"system":{"sshKeys":[]}}`, map[string]string{"If-Match": etag}); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("refused patch: %d", rec.Code)
|
||||
}
|
||||
rec = rest(h, tok, "PATCH", "/api/v1/settings", patch, map[string]string{"If-Match": etag})
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"saved": true`) || rec.Header().Get("ETag") == etag {
|
||||
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = rest(h, tok, "GET", "/api/v1/changes", "", nil)
|
||||
if !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") || !strings.Contains(rec.Body.String(), "gw") {
|
||||
t.Fatalf("changes: %s %s", rec.Header().Get("Content-Type"), rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogs(t *testing.T) {
|
||||
one := 0
|
||||
cases := []struct {
|
||||
q logQuery
|
||||
want []string
|
||||
bad bool
|
||||
}{
|
||||
{logQuery{}, []string{"--lines=100"}, false},
|
||||
{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},
|
||||
}
|
||||
for _, c := range cases {
|
||||
args, err := journalArgs(c.q)
|
||||
if (err != nil) != c.bad {
|
||||
t.Fatalf("%+v: error %v", c.q, err)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !slices.Contains(args, w) {
|
||||
t.Errorf("%+v: %q missing from %q", c.q, w, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
var got []string
|
||||
runJournal = func(name string, args ...string) (string, error) {
|
||||
got = append([]string{name}, args...)
|
||||
return "2026-10-31T22:16:08+0000 router pppd[1]: Connected", nil
|
||||
}
|
||||
t.Cleanup(func() { runJournal = command })
|
||||
_, tok, _ := a.tokens.Create("reader", "read")
|
||||
rec := rest(h, tok, "GET", "/api/v1/logs?unit=router-pppoe&lines=3&boot=-1", "", nil)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Connected") || !slices.Contains(got, "--boot=-1") || !slices.Contains(got, "--lines=3") {
|
||||
t.Fatalf("logs: %d %s %q", rec.Code, rec.Body, got)
|
||||
}
|
||||
if rec := rest(h, tok, "GET", "/api/v1/logs?lines=abc", "", nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad number: %d", rec.Code)
|
||||
}
|
||||
if rec := rest(h, tok, "GET", "/api/v1/logs?unit=-x", "", nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad unit: %d", rec.Code)
|
||||
}
|
||||
text, failed := call(t, h, tok, "get_logs", map[string]any{"unit": "router-webui", "grep": "x"})
|
||||
if failed || !strings.Contains(text, "Connected") || !slices.Contains(got, "--unit=router-webui") {
|
||||
t.Fatalf("mcp get_logs: %v %q", failed, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPI(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
rec := rest(h, "", "GET", "/api/openapi.json", "", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("spec: %d", rec.Code)
|
||||
}
|
||||
var spec struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths map[string]map[string]map[string]any `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &spec); err != nil || spec.OpenAPI != "3.1.0" {
|
||||
t.Fatalf("spec: %v %s", err, spec.OpenAPI)
|
||||
}
|
||||
// Every tool has an operation (the two interfaces stay equal).
|
||||
ops := map[string]bool{}
|
||||
for _, methods := range spec.Paths {
|
||||
for _, op := range methods {
|
||||
ops[op["operationId"].(string)] = true
|
||||
}
|
||||
}
|
||||
for _, tool := range mcpTools {
|
||||
if !ops[tool.name] {
|
||||
t.Errorf("tool %s has no REST operation", tool.name)
|
||||
}
|
||||
}
|
||||
for _, rt := range apiRoutes {
|
||||
if spec.Paths[rt.path][strings.ToLower(rt.method)] == nil {
|
||||
t.Errorf("%s %s is not in the spec", rt.method, rt.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user