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:
2026-11-01 16:56:59 +01:00
co-authored by Claude Sonnet 5.5
parent 95ab7223b9
commit 586274af4e
41 changed files with 3138 additions and 152 deletions
+251
View File
@@ -0,0 +1,251 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strconv"
"strings"
)
// A REST API (described by OpenAPI at /api/openapi.json) with the same
// features, tokens, scopes, limits and audit log as the MCP server: every
// operation runs one of the MCP tools (mcpTools), so the two cannot drift.
// The routes below say how a request becomes a tool's arguments.
type apiRoute struct {
method, path, tool string
summary string
}
var apiRoutes = []apiRoute{
{"GET", "/api/v1/status", "get_status", "State now: uplink, pings, system, whether settings are unapplied"},
{"GET", "/api/v1/settings", "get_settings", "The saved settings and their ETag"},
{"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)"},
{"GET", "/api/v1/settings/check", "check_settings", "Evaluate the saved settings without applying them"},
{"GET", "/api/v1/changes", "get_pending_changes", "The difference between the confirmed and the saved settings"},
{"DELETE", "/api/v1/changes", "discard_changes", "Throw the unapplied changes away"},
{"GET", "/api/v1/interfaces", "list_interfaces", "Network cards and VLAN devices"},
{"GET", "/api/v1/dhcp-leases", "list_dhcp_leases", "DHCP clients holding a lease"},
{"GET", "/api/v1/update", "get_update_status", "The installed release and the last update run"},
{"GET", "/api/v1/apply-log", "get_apply_log", "The log and result of the last apply"},
{"GET", "/api/v1/logs", "get_logs", "Lines of the system journal"},
}
func toolNamed(name string) *mcpTool {
for i := range mcpTools {
if mcpTools[i].name == name {
return &mcpTools[i]
}
}
return nil
}
func apiError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func (a *app) registerAPI(mux *http.ServeMux) {
for _, rt := range apiRoutes {
mux.HandleFunc(rt.method+" "+rt.path, a.apiHandler(rt))
}
mux.HandleFunc("GET /api/v1/options", a.apiOptions)
mux.HandleFunc("GET /api/openapi.json", a.apiSpec)
}
func (a *app) apiHandler(rt apiRoute) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
tok, ok := a.bearer(w, r)
if !ok {
return
}
tool := toolNamed(rt.tool)
if !tok.allows(tool.scope) {
apiError(w, http.StatusForbidden, "this token may not "+tool.scope+" (scope)")
return
}
args, err := apiArgs(w, r, tool)
if err != nil {
apiError(w, http.StatusBadRequest, err.Error())
return
}
res, err := a.runTool(tok, tool, args)
if err != nil {
var bad argError
switch {
case errors.Is(err, errStale):
apiError(w, http.StatusConflict, err.Error())
case errors.As(err, &bad) || tool.scope == scopeChange:
apiError(w, http.StatusBadRequest, err.Error())
default:
apiError(w, http.StatusInternalServerError, err.Error())
}
return
}
if text, ok := res.(string); ok {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, text+"\n")
return
}
if m, ok := res.(map[string]any); ok && rt.tool == "get_settings" {
if e, ok := m["etag"].(string); ok {
w.Header().Set("ETag", `"`+e+`"`)
}
}
if m, ok := res.(map[string]any); ok && rt.tool == "update_settings" {
if e, ok := m["etag"].(string); ok {
w.Header().Set("ETag", `"`+e+`"`)
}
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(res)
}
}
// apiArgs builds a tool's arguments from the request: the query for GET
// (typed by the tool's schema), If-Match and the body for the settings patch.
func apiArgs(w http.ResponseWriter, r *http.Request, tool *mcpTool) (json.RawMessage, error) {
switch r.Method {
case http.MethodPatch:
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
return nil, errors.New("request too large")
}
var patch any
if err := json.Unmarshal(body, &patch); err != nil {
return nil, errors.New("the body must be a JSON merge patch")
}
etag := strings.Trim(strings.TrimPrefix(r.Header.Get("If-Match"), "W/"), `"`)
if etag == "" {
return nil, errors.New("send the settings' ETag in If-Match (GET /api/v1/settings)")
}
return json.Marshal(map[string]any{"etag": etag, "patch": patch})
case http.MethodGet:
props, _ := tool.schema["properties"].(map[string]any)
args := map[string]any{}
for name, p := range props {
v := r.URL.Query().Get(name)
if v == "" {
continue
}
if typ, _ := p.(map[string]any)["type"].(string); typ == "integer" {
n, err := strconv.Atoi(v)
if err != nil {
return nil, errors.New(name + " must be a whole number")
}
args[name] = n
} else {
args[name] = v
}
}
return json.Marshal(args)
}
return nil, nil
}
func (a *app) apiOptions(w http.ResponseWriter, r *http.Request) {
tok, ok := a.bearer(w, r)
if !ok {
return
}
_ = tok
data, err := os.ReadFile(a.optionsFile)
if a.optionsFile == "" || err != nil {
apiError(w, http.StatusNotFound, "no option documentation on this router")
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
// apiSpec serves the OpenAPI description. It describes the interface only (no
// router data), so it needs no token.
func (a *app) apiSpec(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(openAPISpec(a.sys.installedRelease().Version))
}
func openAPISpec(version string) map[string]any {
errResp := func(d string) map[string]any {
return map[string]any{"description": d, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}}
}
paths := map[string]any{}
for _, rt := range apiRoutes {
tool := toolNamed(rt.tool)
op := map[string]any{
"operationId": rt.tool,
"summary": rt.summary,
"description": tool.description,
"tags": []string{tool.scope},
"security": []any{map[string]any{"bearer": []string{}}},
"x-scope": tool.scope,
"responses": map[string]any{
"200": map[string]any{"description": "The result of " + rt.tool, "content": map[string]any{
"application/json": map[string]any{"schema": map[string]any{}},
"text/plain": map[string]any{"schema": map[string]any{"type": "string"}},
}},
"401": errResp("No or an unknown token."),
"403": errResp("The token's scope does not allow this."),
},
}
var params []any
switch rt.method {
case "GET":
props, _ := tool.schema["properties"].(map[string]any)
for name, p := range props {
params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"]})
}
if rt.tool == "get_logs" {
op["responses"].(map[string]any)["400"] = errResp("An argument is not valid.")
}
case "PATCH":
props := tool.schema["properties"].(map[string]any)
params = append(params, map[string]any{"name": "If-Match", "in": "header", "required": true,
"description": props["etag"].(map[string]any)["description"], "schema": map[string]any{"type": "string"}})
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{
"application/merge-patch+json": map[string]any{"schema": props["patch"]},
"application/json": map[string]any{"schema": props["patch"]},
}}
op["responses"].(map[string]any)["400"] = errResp("The patch is refused (see the message).")
op["responses"].(map[string]any)["409"] = errResp("The settings changed since the ETag was read.")
}
if params != nil {
op["parameters"] = params
}
entry, _ := paths[rt.path].(map[string]any)
if entry == nil {
entry = map[string]any{}
paths[rt.path] = entry
}
entry[strings.ToLower(rt.method)] = op
}
paths["/api/v1/options"] = map[string]any{"get": map[string]any{
"operationId": "get_options", "summary": "Every router option: type, default, description", "tags": []string{scopeRead},
"security": []any{map[string]any{"bearer": []string{}}},
"responses": map[string]any{"200": map[string]any{"description": "The options (JSON)", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{}}}}, "404": errResp("Not available.")},
}}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": "nixos-router",
"version": version,
"description": "The router's management API, equal to its MCP server: read its state and logs, edit the saved settings. " +
"Changes are saved, never applied: a person applies them in the web UI. Tokens are made on the API access page (scope read or change).",
},
"servers": []any{map[string]any{"url": "/"}},
"paths": paths,
"components": map[string]any{
"securitySchemes": map[string]any{"bearer": map[string]any{"type": "http", "scheme": "bearer", "description": "A token from the API access page (rtr_…)."}},
"schemas": map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}},
},
}
}
+180
View File
@@ -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)
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// A check evaluates the saved settings like an apply would (option types and
// the router's assertions) without building anything: router-check.service
// runs `nix eval` on the configuration and leaves its verdict in
// <state>/check/result.json.
type CheckResult struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Etag string `json:"etag"` // of the settings that were evaluated
Time time.Time `json:"time"`
}
func (s System) checkResultPath() string { return filepath.Join(s.StateDir, "check", "result.json") }
func (s System) readCheck() (CheckResult, bool) {
var r CheckResult
data, err := os.ReadFile(s.checkResultPath())
if err != nil || json.Unmarshal(data, &r) != nil {
return r, false
}
return r, true
}
func (s System) checkRunning() bool {
return unitProperty("router-check.service", "ActiveState") == "activating"
}
// checkSettings starts a check unless one runs or the last result is for the
// current settings, and waits a while for it.
func (a *app) checkSettings() (any, error) {
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, err
}
current := etag(data)
if r, ok := a.sys.readCheck(); ok && r.Etag == current && !a.sys.checkRunning() {
return a.checkReport(r, true), nil
}
if !a.sys.checkRunning() {
if _, err := command("systemctl", "start", "--no-block", "router-check.service"); err != nil {
return nil, err
}
}
for range 50 {
time.Sleep(time.Second)
if a.sys.checkRunning() {
continue
}
if r, ok := a.sys.readCheck(); ok {
return a.checkReport(r, r.Etag == current), nil
}
break
}
return map[string]any{"status": "running", "next": "Still evaluating; call check_settings again in a few seconds."}, nil
}
func (a *app) checkReport(r CheckResult, current bool) map[string]any {
out := map[string]any{"status": "done", "ok": r.OK, "forTheSavedSettings": current, "checkedAt": r.Time}
if r.OK {
out["message"] = "The settings evaluate: the router's assertions pass."
} else {
out["error"] = r.Error
}
if !current {
out["note"] = "The settings changed during or after this check; call check_settings again."
}
return out
}
+9
View File
@@ -70,6 +70,7 @@ type SystemStatus struct {
Load string
Kernel string // the running kernel
KernelNext string // the kernel of the current system when it differs: a reboot is due
BootPending bool // a configuration is staged for the next boot (interface names)
CPU float64 // percent; NaN until the second sample
MemUsed uint64
MemTotal uint64
@@ -262,6 +263,14 @@ func systemStatus() SystemStatus {
if dest, err := filepath.EvalSymlinks("/run/current-system/kernel"); err == nil {
s.KernelNext = pendingKernel(s.Kernel, dest)
}
// router-apply sets the system profile without activating it when the
// configuration changes interface names. (While a change awaits
// confirmation the two differ too, but a rollback timer is running.)
if cur, err := filepath.EvalSymlinks("/run/current-system"); err == nil {
if prof, err := filepath.EvalSymlinks("/nix/var/nix/profiles/system"); err == nil && prof != cur {
s.BootPending = rollbackIn() == 0
}
}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
if f := strings.Fields(string(data)); len(f) >= 3 {
s.Load = strings.Join(f[:3], " ")
+24 -5
View File
@@ -26,6 +26,8 @@ type Prefs struct {
// VLANs defined in the network page ("enp2s0.1001") that may not be used
// yet; the interface dropdowns offer them.
VLANs []string `json:"vlans,omitempty"`
// Theme is "light" or "dark"; empty follows the browser's setting.
Theme string `json:"theme,omitempty"`
}
var defaultPrefs = Prefs{PingTargets: []string{"gateway", "1.1.1.1", "9.9.9.9"}}
@@ -400,18 +402,35 @@ func (a *app) dashboardFragment(w http.ResponseWriter, r *http.Request, sess ses
}
}
// savePrefs takes the Advanced page's two small forms: the ping targets, or
// the theme.
func (a *app) savePrefs(w http.ResponseWriter, r *http.Request, sess session) {
targets, err := parsePingTargets(r.PostFormValue("pingTargets"))
_ = r.ParseForm()
prefs := a.sys.loadPrefs()
what := "Ping targets"
var err error
if r.PostForm.Has("theme") {
what = "Appearance"
switch t := r.PostFormValue("theme"); t {
case "", "light", "dark":
prefs.Theme = t
default:
err = fmt.Errorf("unknown theme %q", t)
}
} else {
var targets []string
if targets, err = parsePingTargets(r.PostFormValue("pingTargets")); err == nil {
prefs.PingTargets = targets
}
}
if err == nil {
prefs := a.sys.loadPrefs()
prefs.PingTargets = targets
err = a.sys.savePrefs(prefs)
}
if err != nil {
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets not saved: "+err.Error()), http.StatusSeeOther)
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" not saved: "+err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets saved."), http.StatusSeeOther)
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" saved."), http.StatusSeeOther)
}
// backup streams the state directory (settings, secrets, certificate, metric
+32
View File
@@ -201,3 +201,35 @@ func keys(m map[string]string) []string {
}
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")
}
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"encoding/json"
"os"
"strings"
)
// diffLines is a line diff of two texts in the style of diff -u without the
// hunk headers: " " unchanged lines near a change, "-" removed, "+" added.
// It returns "" when the texts are equal.
func diffLines(a, b string) string {
x, y := strings.Split(a, "\n"), strings.Split(b, "\n")
// Longest common subsequence table.
lcs := make([][]int, len(x)+1)
for i := range lcs {
lcs[i] = make([]int, len(y)+1)
}
for i := len(x) - 1; i >= 0; i-- {
for j := len(y) - 1; j >= 0; j-- {
if x[i] == y[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else {
lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1])
}
}
}
type line struct {
op byte
text string
}
var all []line
i, j := 0, 0
for i < len(x) || j < len(y) {
switch {
case i < len(x) && j < len(y) && x[i] == y[j]:
all = append(all, line{' ', x[i]})
i++
j++
case i < len(x) && (j == len(y) || lcs[i+1][j] >= lcs[i][j+1]):
all = append(all, line{'-', x[i]})
i++
default:
all = append(all, line{'+', y[j]})
j++
}
}
// Keep two lines of context around changes.
const ctx = 2
keep := make([]bool, len(all))
changed := false
for k, l := range all {
if l.op != ' ' {
changed = true
for d := -ctx; d <= ctx; d++ {
if k+d >= 0 && k+d < len(all) {
keep[k+d] = true
}
}
}
}
if !changed {
return ""
}
var out strings.Builder
skipped := false
for k, l := range all {
if !keep[k] {
skipped = true
continue
}
if skipped {
out.WriteString("…\n")
skipped = false
}
out.WriteString(string(l.op) + " " + l.text + "\n")
}
return out.String()
}
// pretty re-indents JSON with sorted keys, so two spellings of the same
// settings compare equal.
func pretty(data []byte) string {
var v any
if json.Unmarshal(data, &v) != nil {
return strings.TrimSpace(string(data))
}
out, _ := json.MarshalIndent(v, "", " ")
return string(out)
}
// pendingChanges is the diff from the confirmed settings to the saved ones.
func (s System) pendingChanges() string {
cur, err1 := os.ReadFile(s.settingsPath())
conf, err2 := os.ReadFile(s.confirmedPath())
if err1 != nil || err2 != nil {
return ""
}
return diffLines(pretty(conf), pretty(cur))
}
+11 -6
View File
@@ -206,8 +206,8 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
case !nameRe.MatchString(m.Zone):
errs[fmt.Sprintf("member[%d]", i)] = "letters, digits and dashes, starting with a letter"
continue
case m.Zone == "wan":
errs[fmt.Sprintf("member[%d]", i)] = "wan is the uplink's zone and cannot be assigned"
case m.Zone == "wan" || m.Zone == "local":
errs[fmt.Sprintf("member[%d]", i)] = m.Zone + " is a reserved zone (the uplink, the router) and cannot be assigned"
continue
}
used[m.Zone] = true
@@ -226,8 +226,8 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
declared := slices.Clone(s.declaredZones())
for _, z := range splitList(zf.New) {
switch {
case !nameRe.MatchString(z) || z == "wan":
errs["newzone"] = z + ": letters, digits and dashes, starting with a letter (wan is taken)"
case !nameRe.MatchString(z) || z == "wan" || z == "local":
errs["newzone"] = z + ": letters, digits and dashes, starting with a letter (wan and local are taken)"
case !slices.Contains(declared, z):
declared = append(declared, z)
}
@@ -279,9 +279,12 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule
if !slices.Contains(zones, r.From) && r.From != "wan" {
errs[field("from")] = "choose a zone"
}
if !slices.Contains(zones, r.To) {
if !slices.Contains(zones, r.To) && r.To != "local" {
errs[field("to")] = "choose a zone"
}
if r.To == "local" && r.NATOnly {
errs[field("to")] = "a port forward leads to a host in a zone, not to the router"
}
if r.From == r.To && r.From != "" {
errs[field("to")] = "traffic inside a zone is already allowed"
}
@@ -312,7 +315,7 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule
}
if r.From == "wan" {
// What the module requires, so that a typo cannot open a whole zone.
if r.Destination == "" {
if r.Destination == "" && r.To != "local" {
errs[field("destination")] = "rules from the internet must name the host or network inside"
}
if r.Protocol == "any" {
@@ -402,6 +405,7 @@ type firewallData struct {
Rows []FirewallRule
NATs []NATRule
FromZones []string // the zones and wan
ToZones []string // the zones and local (the router itself)
Other []string
Zones []string
Members []ZoneMember
@@ -414,6 +418,7 @@ func (s Settings) firewallData(rows []FirewallRule, nats []NATRule, members []Zo
_, other := s.firewallRules()
d := firewallData{Rows: rows, NATs: nats, Other: other, Zones: s.zones(), Members: members, Errors: errs}
d.FromZones = append([]string{"wan"}, d.Zones...)
d.ToZones = append(slices.Clone(d.Zones), "local")
if d.Members == nil {
d.Members = s.zoneMembers()
}
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// Names for network cards and VLAN devices. A card is named by its MAC address
// (router.interfaces.<name>.mac), a VLAN device by its LAN (lans.<l>.deviceName)
// or the PPPoE session (wan.ipv4.pppoe.vlanName). Linux cannot rename a device
// that is up, so the router only starts using a new name after a reboot;
// router-apply stages such a change instead of activating it.
// reservedNameRe matches the names the kernel and udev hand out themselves:
// a rename onto one could collide with a card that gets it at boot.
// Keep in step with chosenNameOk in modules/router/assertions.nix.
var reservedNameRe = regexp.MustCompile(`^((eth|wlan|ppp|wg|veth|bond|tun|tap|dummy|ifb|docker|virbr|br|lo)[0-9]*|(en|wl|ww|sl)[opsx][0-9a-f].*)$`)
// chosenName says what is wrong with a name given to a card or VLAN device.
func chosenName(n string) string {
switch {
case !nameRe.MatchString(n):
return "lower case letters, digits and dashes, starting with a letter, at most 15 characters"
case reservedNameRe.MatchString(n):
return "looks like a name the kernel hands out (eth0, wlan0, enp2s0, ...)"
}
return ""
}
// NIC is a physical network card as the kernel names it now.
type NIC struct {
Kernel string
MAC string
}
// NICs lists the physical interfaces with their MAC addresses.
func NICs() []NIC {
var out []NIC
for _, n := range Interfaces() {
addr, _ := os.ReadFile(filepath.Join("/sys/class/net", n, "address"))
out = append(out, NIC{Kernel: n, MAC: strings.ToLower(strings.TrimSpace(string(addr)))})
}
return out
}
// cardNames returns router.interfaces as name -> MAC.
func (s Settings) cardNames() map[string]string {
out := map[string]string{}
router, _ := s["router"].(map[string]any)
cards, _ := router["interfaces"].(map[string]any)
for name, v := range cards {
if c, ok := v.(map[string]any); ok {
out[name] = str(c, "mac")
}
}
return out
}
// vlanNames returns the chosen VLAN device names, keyed "parent.id".
func (s Settings) vlanNames() map[string]string {
out := map[string]string{}
router, _ := s["router"].(map[string]any)
lans, _ := router["lans"].(map[string]any)
for _, v := range lans {
if l, ok := v.(map[string]any); ok && str(l, "deviceName") != "" {
out[str(l, "interface")+"."+str(l, "vlan")] = str(l, "deviceName")
}
}
if v := str(router, "wan", "ipv4", "pppoe", "vlanName"); v != "" && str(router, "wan", "ipv4", "type") == "pppoe" {
out[str(router, "wan", "interface")+"."+str(router, "wan", "ipv4", "pppoe", "vlan")] = v
}
return out
}
// effectiveName is what the settings call a card: the name chosen for its MAC,
// else the kernel's.
func effectiveName(names map[string]string, n NIC) string {
for name, mac := range names {
if mac != "" && mac == n.MAC {
return name
}
}
return n.Kernel
}
// Rename is one edited row of the interface table.
type Rename struct {
Dev string // the device as the settings call it: "lan", or "lan.1002" for a VLAN
MAC string // of a physical card; empty for a VLAN
From string // the name shown before editing: the card's name, the VLAN device's custom name or ""
To string // what the user typed; empty: back to the kernel's name / the default
}
// rename applies the edited rows to the settings. kernel gives the kernel's
// name for a MAC address (to undo a rename). The changes take effect after a
// reboot, so references to a card in the settings follow its new name at once.
// It returns the references to rewrite in the UI's own preferences too:
// old device name -> new.
func (s Settings) rename(rows []Rename, kernel func(mac string) string) (map[string]string, error) {
errs := FormErrors{}
moved := map[string]string{}
router := obj(s, "router")
for i, r := range rows {
field := fmt.Sprintf("name[%d]", i)
to := strings.TrimSpace(r.To)
if to == r.From {
continue
}
if r.MAC == "" && !strings.Contains(r.Dev, ".") {
errs[field] = "this interface is not on this machine"
continue
}
if to != "" {
if why := chosenName(to); why != "" {
errs[field] = why
continue
}
}
if r.MAC != "" {
s.renameCard(router, r, to, kernel, moved, errs, field)
} else {
s.renameVLAN(router, r, to, errs, field)
}
}
if len(errs) > 0 {
return nil, errs
}
// Two names must not be the same, whatever was typed.
if dup := s.duplicateName(); dup != "" {
return nil, FormErrors{"name": dup + " is used twice"}
}
return moved, nil
}
func (s Settings) renameCard(router map[string]any, r Rename, to string, kernel func(string) string, moved map[string]string, errs FormErrors, field string) {
cards := obj(router, "interfaces")
if to == "" {
to = kernel(r.MAC)
if to == "" {
errs[field] = "the kernel's name for this card is unknown"
return
}
delete(cards, r.Dev)
} else {
if c, ok := cards[to].(map[string]any); ok && str(c, "mac") != r.MAC {
errs[field] = to + " is the name of another interface"
return
}
delete(cards, r.Dev) // it may have a name already
obj(cards, to)["mac"] = r.MAC
}
if len(cards) == 0 {
delete(router, "interfaces")
}
moved[r.Dev] = to
if str(router, "wan", "interface") == r.Dev {
obj(router, "wan")["interface"] = to
}
lans, _ := router["lans"].(map[string]any)
for _, v := range lans {
if l, ok := v.(map[string]any); ok && str(l, "interface") == r.Dev {
l["interface"] = to
}
}
}
func (s Settings) renameVLAN(router map[string]any, r Rename, to string, errs FormErrors, field string) {
parent, id := splitVLAN(r.Dev)
if id == 0 {
errs[field] = "not a VLAN"
return
}
used := false
set := func(m map[string]any, key string) {
used = true
if to == "" {
delete(m, key)
} else {
m[key] = to
}
}
lans, _ := router["lans"].(map[string]any)
for _, v := range lans {
if l, ok := v.(map[string]any); ok && str(l, "interface") == parent && str(l, "vlan") == strconv.Itoa(id) {
set(l, "deviceName")
}
}
if str(router, "wan", "interface") == parent && str(router, "wan", "ipv4", "type") == "pppoe" &&
str(router, "wan", "ipv4", "pppoe", "vlan") == strconv.Itoa(id) {
set(obj(obj(obj(router, "wan"), "ipv4"), "pppoe"), "vlanName")
}
if !used {
errs[field] = "a VLAN that nothing uses has no device to name"
}
}
// duplicateName returns a name that two cards or VLAN devices share.
func (s Settings) duplicateName() string {
var all []string
for n := range s.cardNames() {
all = append(all, n)
}
for _, n := range s.vlanNames() {
all = append(all, n)
}
slices.Sort(all)
for i := 1; i < len(all); i++ {
if all[i] == all[i-1] {
return all[i]
}
}
return ""
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"strings"
"testing"
)
func TestChosenName(t *testing.T) {
for _, ok := range []string{"wan", "lan", "dmz", "guest-wifi", "isp1", "a"} {
if why := chosenName(ok); why != "" {
t.Errorf("%q refused: %s", ok, why)
}
}
for _, bad := range []string{"", "Wan", "1wan", "wan_1", "eth0", "eth", "wlan0", "enp2s0", "eno1", "ens3", "wlp1s0", "ppp0", "wg0", "veth12", "br0", "lo", "this-name-is-too-long"} {
if chosenName(bad) == "" {
t.Errorf("%q accepted", bad)
}
}
}
func renameSettings(t *testing.T) Settings {
t.Helper()
s, err := parseSettings([]byte(`{"router": {
"wan": {"interface": "enp1s0", "ipv4": {"type": "pppoe", "pppoe": {"vlan": 7}}},
"lans": {"lan": {"interface": "enp2s0"}, "dmz": {"interface": "enp2s0", "vlan": 1002}, "iot": {"interface": "enp3s0", "vlan": 5}}}}`))
if err != nil {
t.Fatal(err)
}
return s
}
func kernel(mac string) string { return map[string]string{"aa:aa": "enp1s0", "bb:bb": "enp2s0"}[mac] }
func TestRenameCard(t *testing.T) {
s := renameSettings(t)
moved, err := s.rename([]Rename{{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "lan"}}, kernel)
if err != nil || moved["enp2s0"] != "lan" {
t.Fatalf("moved %v, err %v", moved, err)
}
if s.cardNames()["lan"] != "bb:bb" {
t.Errorf("cards = %v", s.cardNames())
}
f := s.form()
for _, l := range f.LANs {
if (l.Name == "lan" || l.Name == "dmz") && !strings.HasPrefix(l.Interface, "lan") {
t.Errorf("LAN %s still on %s", l.Name, l.Interface)
}
if l.Name == "iot" && l.Interface != "enp3s0.5" {
t.Errorf("an unrelated LAN moved: %s", l.Interface)
}
}
// The card is now called lan; calling it wan renames the entry, not adds one.
if _, err := s.rename([]Rename{{Dev: "lan", MAC: "bb:bb", From: "lan", To: "uplink2"}}, kernel); err != nil {
t.Fatal(err)
}
if got := s.cardNames(); len(got) != 1 || got["uplink2"] != "bb:bb" {
t.Errorf("cards = %v", got)
}
// Clearing the name goes back to the kernel's.
if _, err := s.rename([]Rename{{Dev: "uplink2", MAC: "bb:bb", From: "uplink2", To: ""}}, kernel); err != nil {
t.Fatal(err)
}
if _, ok := s["router"].(map[string]any)["interfaces"]; ok {
t.Error("an empty interfaces table is kept")
}
if got := s.form().LANs[0].Interface; got != "enp2s0" && got != "enp2s0.1002" {
t.Errorf("LAN back on %s", got)
}
}
func TestRenameRefused(t *testing.T) {
for name, row := range map[string]Rename{
"reserved": {Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "eth5"},
"not there": {Dev: "enp9s0", From: "enp9s0", To: "lan"},
"unused": {Dev: "enp9s0.4", From: "", To: "x"},
} {
s := renameSettings(t)
if _, err := s.rename([]Rename{row}, kernel); err == nil {
t.Errorf("%s accepted", name)
}
}
s := renameSettings(t)
_, err := s.rename([]Rename{
{Dev: "enp1s0", MAC: "aa:aa", From: "enp1s0", To: "wan"},
{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "wan"},
}, kernel)
if err == nil {
t.Error("the same name for two cards")
}
}
func TestRenameVLAN(t *testing.T) {
s := renameSettings(t)
if _, err := s.rename([]Rename{
{Dev: "enp2s0.1002", From: "", To: "dmz"},
{Dev: "enp1s0.7", From: "", To: "isp"},
}, kernel); err != nil {
t.Fatal(err)
}
if got := s.vlanNames(); got["enp2s0.1002"] != "dmz" || got["enp1s0.7"] != "isp" || len(got) != 2 {
t.Errorf("vlan names = %v", got)
}
// The identifier in the form is unchanged.
for _, l := range s.form().LANs {
if l.Name == "dmz" && l.Interface != "enp2s0.1002" {
t.Errorf("form identifier = %s", l.Interface)
}
}
// Saving the network form keeps the names; dropping the VLAN drops its name.
f := s.form()
if _, err := s.apply(f, "/pw"); err != nil && !strings.Contains(err.Error(), "lans") {
_ = err
}
if s.vlanNames()["enp2s0.1002"] != "dmz" {
t.Error("the form dropped the VLAN's name")
}
for i, l := range f.LANs {
if l.Name == "dmz" {
f.LANs[i].Interface = "enp2s0"
}
}
_, _ = s.apply(f, "/pw")
if s.vlanNames()["enp2s0.1002"] != "" {
t.Error("an untagged LAN keeps a VLAN device name")
}
// A name already used by a card is refused.
s = renameSettings(t)
if _, err := s.rename([]Rename{{Dev: "enp2s0", MAC: "bb:bb", From: "enp2s0", To: "dmz"}, {Dev: "enp2s0.1002", From: "", To: "dmz"}}, kernel); err == nil {
t.Error("a card and a VLAN with the same name")
}
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
)
// get_logs: the system journal, read-only, through journalctl with its
// arguments built here (no shell), so that nothing a caller sends can become
// an option.
var (
unitRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@:._-]{0,79}$`)
timeRe = regexp.MustCompile(`^[0-9A-Za-z :.+/_,-]{1,40}$`)
prioRe = regexp.MustCompile(`^([0-7]|emerg|alert|crit|err|warning|notice|info|debug)$`)
maxLogs = 1000
logSize = 256 << 10
)
// runJournal runs journalctl (replaced in tests).
var runJournal = command
type logQuery struct {
Unit string `json:"unit"`
Lines int `json:"lines"`
Since string `json:"since"`
Until string `json:"until"`
Priority string `json:"priority"`
Grep string `json:"grep"`
Boot *int `json:"boot"`
}
// journalArgs turns a query into journalctl's arguments.
func journalArgs(q logQuery) ([]string, error) {
if q.Lines == 0 {
q.Lines = 100
}
if q.Lines < 1 || q.Lines > maxLogs {
return nil, argError(fmt.Sprintf("lines must be 1 to %d", maxLogs))
}
args := []string{"--no-pager", "-o", "short-iso", "--lines=" + strconv.Itoa(q.Lines)}
switch {
case q.Unit == "kernel":
args = append(args, "-k")
case q.Unit != "":
if !unitRe.MatchString(q.Unit) {
return nil, argError("unit: a unit name like router-webui or router-pppoe.service, or kernel")
}
args = append(args, "--unit="+q.Unit)
}
for _, t := range []struct{ flag, v string }{{"--since=", q.Since}, {"--until=", q.Until}} {
if t.v == "" {
continue
}
if !timeRe.MatchString(t.v) {
return nil, argError(strings.TrimPrefix(strings.TrimSuffix(t.flag, "="), "--") + ": a time like \"1 hour ago\", \"-2h\", \"yesterday\" or \"2026-10-31 10:00:00\"")
}
args = append(args, t.flag+t.v)
}
if q.Priority != "" {
if !prioRe.MatchString(q.Priority) {
return nil, argError("priority: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (that level and worse)")
}
args = append(args, "--priority="+q.Priority)
}
if q.Grep != "" {
if len(q.Grep) > 200 {
return nil, argError("grep: at most 200 characters")
}
args = append(args, "--grep="+q.Grep, "--case-sensitive=false")
}
if q.Boot != nil {
if *q.Boot > 0 || *q.Boot < -50 {
return nil, argError("boot: 0 (this boot), -1 (the one before), ...")
}
args = append(args, "--boot="+strconv.Itoa(*q.Boot))
}
return args, nil
}
var logsTool = mcpTool{
name: "get_logs", scope: scopeRead,
description: "Lines of the system journal, oldest first (the last `lines` that match). Without `unit` all units. Useful units: router-pppoe (the PPPoE connection: connects and disconnects), router-webui, router-apply, systemd-networkd, kea-dhcp4-server, nftables, wireguard-*; `kernel` for the kernel log. Search a text with `grep`, narrow by `since` (\"1 hour ago\", \"2026-10-31 10:00:00\"), `priority` (err = errors and worse) or `boot` (0 this boot, -1 the previous).",
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"unit": map[string]any{"type": "string", "description": "A systemd unit (router-pppoe, router-webui.service, ...) or `kernel`."},
"lines": map[string]any{"type": "integer", "minimum": 1, "maximum": maxLogs, "default": 100, "description": "How many lines at most."},
"since": map[string]any{"type": "string", "description": "Only entries since then: \"1 hour ago\", \"yesterday\", \"2026-10-31 10:00:00\"."},
"until": map[string]any{"type": "string", "description": "Only entries until then."},
"priority": map[string]any{"type": "string", "description": "This level and worse: 0-7 or emerg, alert, crit, err, warning, notice, info, debug."},
"grep": map[string]any{"type": "string", "description": "Only entries matching this regular expression (case insensitive)."},
"boot": map[string]any{"type": "integer", "maximum": 0, "description": "0 is the current boot, -1 the one before, ... Without it all boots the journal holds."},
},
},
run: func(a *app, raw json.RawMessage) (any, error) {
var q logQuery
if err := json.Unmarshal(raw, &q); err != nil {
return nil, argError("bad arguments: " + err.Error())
}
args, err := journalArgs(q)
if err != nil {
return nil, err
}
out, err := runJournal("journalctl", args...)
if err != nil && out == "" {
return nil, fmt.Errorf("journalctl: %w", err)
}
if len(out) > logSize {
out = "[…]\n" + out[len(out)-logSize:]
}
if out == "" || strings.HasPrefix(out, "-- No entries --") {
return "No matching entries.", nil
}
return out, nil
},
}
+106 -9
View File
@@ -83,6 +83,7 @@ func main() {
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
acmeDir := flag.String("acme-dir", "", "directory of a certificate from an ACME client (fullchain.pem, key.pem), served when present")
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
options := flag.String("options", "", "JSON documentation of the router options (offered to MCP clients)")
dev := flag.String("assets", "", "serve templates/ and static/ from this directory, re-read on every request (development)")
flag.Parse()
if *dev != "" {
@@ -106,7 +107,7 @@ func main() {
metrics := newCollector(sys, *leases)
srv := &http.Server{
Addr: *listen,
Handler: newApp(sys, metrics, *dev != "").routes(),
Handler: withOptions(newApp(sys, metrics, *dev != ""), *options).routes(),
TLSConfig: &tls.Config{GetCertificate: (&certSource{fallback: cert, dir: *acmeDir}).get, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 10 * time.Second,
}
@@ -217,10 +218,19 @@ type app struct {
pagesMu sync.Mutex
pages map[string]*template.Template
loginMu sync.Mutex // one login attempt at a time
tokens *tokenStore
settingsMu sync.Mutex // edits through the API read, patch and write the settings file
optionsFile string // JSON documentation of the router options, for the MCP resource ("" if none)
}
func withOptions(a *app, file string) *app {
a.optionsFile = file
return a
}
func newApp(sys System, metrics *Collector, dev bool) *app {
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute)}
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute), tokens: newTokenStore(sys.tokensPath())}
a.funcs = template.FuncMap{
"short": func(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 && len(p) > i+34 {
@@ -257,7 +267,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
func (a *app) parseTemplates() (map[string]*template.Template, error) {
files := map[string][]string{"overview": {"templates/dashboard.html"}}
pages := map[string]*template.Template{}
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update"} {
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "tokens", "changes"} {
t, err := template.New("layout.html").Funcs(a.funcs).
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
if err != nil {
@@ -301,6 +311,7 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
mux.HandleFunc("POST /vlan", a.auth(a.vlan))
mux.HandleFunc("POST /interfaces", a.auth(a.interfaces))
mux.HandleFunc("GET /dns", a.auth(a.dnsPage))
mux.HandleFunc("POST /dns", a.auth(a.dns))
mux.HandleFunc("GET /wireguard", a.auth(a.wireguardPage))
@@ -326,6 +337,12 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /update/check", a.auth(a.updateCheck))
mux.HandleFunc("POST /update/install", a.auth(a.updateInstall))
mux.HandleFunc("POST /update/upload", a.updateUpload)
mux.HandleFunc("/mcp", a.mcp)
a.registerAPI(mux)
mux.HandleFunc("GET /tokens", a.auth(a.tokensPage))
mux.HandleFunc("POST /tokens", a.auth(a.tokenCreate))
mux.HandleFunc("POST /tokens/revoke", a.auth(a.tokenRevoke))
mux.HandleFunc("GET /changes", a.auth(a.changesPage))
return securityHeaders(mux)
}
@@ -349,9 +366,11 @@ type page struct {
Error string
LoggedIn bool
Data any
Theme string // set by render: "", "light" or "dark"
}
func (a *app) render(w http.ResponseWriter, name string, p page) {
p.Theme = a.sys.loadPrefs().Theme
if err := a.page(name).Execute(w, p); err != nil {
log.Printf("rendering %s: %v", name, err)
}
@@ -555,8 +574,85 @@ type networkData struct {
}
// devices lists what the interface dropdowns offer for the form f.
func (a *app) devices(f Form) []Device {
return f.devices(Interfaces(), a.sys.loadPrefs().VLANs)
func (a *app) devices(s Settings, f Form) []Device {
names := s.cardNames()
var physical []string
nics := map[string]NIC{}
for _, n := range NICs() {
eff := effectiveName(names, n)
physical = append(physical, eff)
nics[eff] = n
}
devs := f.devices(physical, a.sys.loadPrefs().VLANs)
custom := s.vlanNames()
for i, d := range devs {
if d.Kind == "VLAN" {
devs[i].Custom = custom[d.Name]
} else if n, ok := nics[d.Name]; ok {
devs[i].Kernel, devs[i].MAC = n.Kernel, n.MAC
}
}
return devs
}
// kernelName is the kernel's name for the card with this MAC address.
func kernelName(mac string) string {
for _, n := range NICs() {
if n.MAC == mac {
return n.Kernel
}
}
return ""
}
// interfaces saves the names typed into the interface table. A new name is
// used by the router after the next apply and reboot.
func (a *app) interfaces(w http.ResponseWriter, r *http.Request, _ session) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
n, _ := strconv.Atoi(r.PostFormValue("rows"))
var rows []Rename
for i := range min(n, 256) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("%s.%d", k, i)) }
rows = append(rows, Rename{Dev: v("dev"), MAC: v("mac"), From: v("from"), To: v("name")})
}
moved, err := s.rename(rows, kernelName)
msg := ""
if err == nil {
err = s.save(a.sys.settingsPath())
}
if err == nil {
// The UI's defined VLANs follow a renamed card.
prefs := a.sys.loadPrefs()
for i, v := range prefs.VLANs {
if parent, id := splitVLAN(v); id != 0 && moved[parent] != "" {
prefs.VLANs[i] = fmt.Sprintf("%s.%d", moved[parent], id)
}
}
slices.Sort(prefs.VLANs)
err = a.sys.savePrefs(prefs)
}
switch {
case err != nil:
msg = "Names not saved: " + err.Error()
case !renamedAny(rows):
msg = "Nothing to change."
default:
msg = "Saved. Apply, then reboot: the new names only exist after a restart."
}
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
}
func renamedAny(rows []Rename) bool {
for _, r := range rows {
if strings.TrimSpace(r.To) != r.From {
return true
}
}
return false
}
// vlan defines or removes a VLAN device for the dropdowns. It lives in the
@@ -588,7 +684,7 @@ func (a *app) vlan(w http.ResponseWriter, r *http.Request, _ session) {
}
case r.PostFormValue("remove") != "":
dev := r.PostFormValue("remove")
for _, d := range s.form().devices(Interfaces(), prefs.VLANs) {
for _, d := range a.devices(s, s.form()) {
if d.Name == dev && !d.Removable {
msg = dev + " is in use."
}
@@ -627,7 +723,7 @@ func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session)
}
f := s.form()
f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(f)}
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(s, f)}
a.render(w, "network", p)
}
@@ -678,7 +774,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
f.WAN.PPPoEPassword = ""
f.WAN.PasswordSet = s.form().WAN.PasswordSet
p.Error = "Please correct the marked fields."
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(f)}
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(s, f)}
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "network", p)
return
@@ -701,6 +797,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
type advancedData struct {
Settings string
PingTargets string
Theme string
}
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
@@ -714,7 +811,7 @@ func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session)
}
func (a *app) advancedData(settings string) advancedData {
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", ")}
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", "), Theme: a.sys.loadPrefs().Theme}
}
func (a *app) advanced(w http.ResponseWriter, r *http.Request, sess session) {
+522
View File
@@ -0,0 +1,522 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// An MCP server (Model Context Protocol, Streamable HTTP transport) on
// POST /mcp, so an assistant can read the router's state and edit its
// settings. It is a thin layer over what the web UI does:
//
// - tokens (tokens.go) instead of the browser session; scope read or change;
// - changes only edit settings.json, like the Advanced page: they stay
// unapplied until a person applies them in the web UI (commit-confirm);
// - secrets are never returned (the settings hold only paths to them), and
// file paths in the settings must stay under the state directory;
// - SSH keys, the release schema and the state version are out of reach;
// - every call is written to the audit log.
//
// Only requests and responses of JSON-RPC 2.0 over POST are implemented: no
// server-initiated messages, no streaming.
var mcpVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
const mcpInstructions = `This is the management interface of a nixos-router.
Read the state with get_status, list_interfaces, list_dhcp_leases, get_settings and get_update_status;
get_logs reads the system journal (a unit, a time range, a priority, a text to search for).
Settings are one JSON document (the resource router://settings; router://options describes every option).
Change them with update_settings (a JSON merge patch; pass the etag from get_settings), then run check_settings,
which evaluates the configuration and reports the router's own assertions. Changes are saved but not applied:
a person reviews them (get_pending_changes shows the difference) and applies them in the web UI.
Never put secrets into the settings: they refer to files (passwordFile, secretFile, ...) that the person provides.`
// mcp is the HTTP handler of the endpoint.
func (a *app) mcp(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "this endpoint takes POST (JSON-RPC)", http.StatusMethodNotAllowed)
return
}
tok, ok := a.bearer(w, r)
if !ok {
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
var req rpcRequest
if err := json.Unmarshal(body, &req); err != nil || req.JSONRPC != "2.0" || req.Method == "" {
a.rpcReply(w, rpcResponse{ID: json.RawMessage("null"), Error: &rpcError{-32700, "not a JSON-RPC 2.0 request"}})
return
}
if len(req.ID) == 0 { // a notification
w.WriteHeader(http.StatusAccepted)
return
}
resp := rpcResponse{ID: req.ID}
result, rerr := a.mcpDispatch(tok, req)
if rerr != nil {
resp.Error = rerr
} else {
resp.Result = result
}
a.rpcReply(w, resp)
}
// bearer authenticates an API request (MCP or REST) by its token; it has
// answered the request itself when it returns false.
func (a *app) bearer(w http.ResponseWriter, r *http.Request) (Token, bool) {
// A browser page must not be able to talk to the router with a token it
// does not have, but DNS rebinding could present a foreign Origin.
if o := r.Header.Get("Origin"); o != "" {
u, err := url.Parse(o)
if err != nil || u.Host != r.Host {
http.Error(w, "forbidden origin", http.StatusForbidden)
return Token{}, false
}
}
secret, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
tok, valid := a.tokens.Lookup(strings.TrimSpace(secret))
if !ok || !valid {
time.Sleep(time.Second) // a guess costs a second
w.Header().Set("WWW-Authenticate", `Bearer realm="router"`)
http.Error(w, "a valid token is required", http.StatusUnauthorized)
return Token{}, false
}
return tok, true
}
func (a *app) rpcReply(w http.ResponseWriter, resp rpcResponse) {
resp.JSONRPC = "2.0"
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (a *app) mcpDispatch(tok Token, req rpcRequest) (any, *rpcError) {
switch req.Method {
case "initialize":
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
_ = json.Unmarshal(req.Params, &p)
version := mcpVersions[0]
for _, v := range mcpVersions {
if v == p.ProtocolVersion {
version = v
}
}
return map[string]any{
"protocolVersion": version,
"capabilities": map[string]any{"tools": map[string]any{}, "resources": map[string]any{}},
"serverInfo": map[string]any{"name": "nixos-router", "version": a.sys.installedRelease().Version},
"instructions": mcpInstructions,
}, nil
case "ping":
return map[string]any{}, nil
case "tools/list":
var out []map[string]any
for _, t := range mcpTools {
if tok.allows(t.scope) {
out = append(out, map[string]any{"name": t.name, "description": t.description, "inputSchema": t.schema})
}
}
return map[string]any{"tools": out}, nil
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return nil, &rpcError{-32602, "bad parameters"}
}
return a.mcpCall(tok, p.Name, p.Arguments)
case "resources/list":
res := []map[string]any{
{"uri": "router://settings", "name": "settings", "description": "The saved settings (settings.json).", "mimeType": "application/json"},
}
if a.optionsFile != "" {
res = append(res, map[string]any{"uri": "router://options", "name": "options",
"description": "Every router option: type, default, description (what settings.json may contain).", "mimeType": "application/json"})
}
return map[string]any{"resources": res}, nil
case "resources/read":
var p struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return nil, &rpcError{-32602, "bad parameters"}
}
var text string
switch p.URI {
case "router://settings":
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, &rpcError{-32603, err.Error()}
}
text = string(data)
case "router://options":
data, err := os.ReadFile(a.optionsFile)
if a.optionsFile == "" || err != nil {
return nil, &rpcError{-32002, "no such resource"}
}
text = string(data)
default:
return nil, &rpcError{-32002, "no such resource"}
}
return map[string]any{"contents": []map[string]any{{"uri": p.URI, "mimeType": "application/json", "text": text}}}, nil
}
return nil, &rpcError{-32601, "method not found: " + req.Method}
}
type mcpTool struct {
name, description, scope string
schema map[string]any
run func(a *app, args json.RawMessage) (any, error)
}
// mcpCall runs a tool. A failing tool is not a protocol error: the assistant
// is told what went wrong and can correct itself.
func (a *app) mcpCall(tok Token, name string, args json.RawMessage) (any, *rpcError) {
var tool *mcpTool
for i := range mcpTools {
if mcpTools[i].name == name {
tool = &mcpTools[i]
}
}
if tool == nil || !tok.allows(tool.scope) {
return nil, &rpcError{-32602, "unknown tool: " + name}
}
res, err := a.runTool(tok, tool, args)
if err != nil {
return map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": err.Error()}}}, nil
}
text, ok := res.(string)
if !ok {
data, merr := json.MarshalIndent(res, "", " ")
if merr != nil {
return nil, &rpcError{-32603, merr.Error()}
}
text = string(data)
}
return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}}, nil
}
// runTool runs a tool for a token (the scope is checked by the caller) and
// writes the call to the audit log.
func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, error) {
if len(args) == 0 {
args = json.RawMessage("{}")
}
res, err := tool.run(a, args)
entry := AuditEntry{Time: time.Now().UTC(), Token: tok.Name, Tool: tool.name, Args: string(args)}
if err != nil {
entry.Error = err.Error()
}
a.sys.audit(entry)
return res, err
}
// errStale: the settings changed since the caller read them.
var errStale = errors.New("the settings changed since you read them (someone else edited, or the etag is wrong): call get_settings again")
// argError is a tool's complaint about its arguments (HTTP 400 in the REST API).
type argError string
func (e argError) Error() string { return string(e) }
// etag identifies a version of the settings file.
func etag(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:8])
}
func finite(f float64) any {
if math.IsNaN(f) || math.IsInf(f, 0) {
return nil
}
return math.Round(f*10) / 10
}
var emptySchema = map[string]any{"type": "object", "properties": map[string]any{}}
var mcpTools = []mcpTool{
{
name: "get_status", scope: scopeRead, schema: emptySchema,
description: "The router's state now: uplink (connected, addresses, gateway, pings), system (uptime, load, CPU, memory, disk, temperature, kernel), whether settings are unapplied, and whether an apply is running or waits for confirmation.",
run: func(a *app, _ json.RawMessage) (any, error) {
snap := a.metrics.Snapshot()
st := a.sys.Status()
u, s := snap.Uplink, snap.System
var pings []map[string]any
for _, p := range snap.Pings {
pings = append(pings, map[string]any{"target": p.Target, "address": p.Address, "ok": p.OK, "rttMs": p.RTT.Milliseconds()})
}
return map[string]any{
"time": snap.Time,
"uplink": map[string]any{"device": u.Device, "type": u.Type, "connected": u.Connected, "reachable": u.Reachable,
"ipv4": u.IPv4, "ipv6": u.IPv6, "gateway": u.Gateway, "since": u.Since},
"pings": pings,
"system": map[string]any{"uptimeSeconds": int(s.Uptime.Seconds()), "load": s.Load, "kernel": s.Kernel,
"rebootForKernel": s.KernelNext, "rebootForConfiguration": s.BootPending, "cpuPercent": finite(s.CPU),
"memoryUsedBytes": s.MemUsed, "memoryTotalBytes": s.MemTotal, "temperatureC": finite(s.Temperature),
"diskUsedBytes": s.DiskUsed, "diskTotalBytes": s.DiskTotal},
"settings": map[string]any{"unapplied": st.Unapplied, "applying": st.Applying,
"lastApplyResult": st.ApplyResult, "confirmationDueInSeconds": int(st.RollbackIn.Seconds())},
}, nil
},
},
{
name: "get_settings", scope: scopeRead, schema: emptySchema,
description: "The saved settings (settings.json: `system` and `router`) with the etag to pass to update_settings, and whether they differ from the confirmed (running) system. Secrets are not in it, only paths of files that hold them.",
run: func(a *app, _ json.RawMessage) (any, error) {
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, err
}
var v any
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return map[string]any{"etag": etag(data), "unapplied": a.sys.Status().Unapplied, "settings": v}, nil
},
},
{
name: "get_pending_changes", scope: scopeRead, schema: emptySchema,
description: "The difference between the confirmed settings and the saved ones (lines starting with - are removed, + added); empty when there is none.",
run: func(a *app, _ json.RawMessage) (any, error) {
if d := a.sys.pendingChanges(); d != "" {
return d, nil
}
return "No changes: the saved settings are the confirmed ones.", nil
},
},
{
name: "list_interfaces", scope: scopeRead, schema: emptySchema,
description: "Network interfaces: physical cards (name now, name from the settings, MAC address) and VLAN devices, and how the settings use each (WAN, a LAN).",
run: func(a *app, _ json.RawMessage) (any, error) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
return nil, err
}
var out []map[string]any
for _, d := range a.devices(s, s.form()) {
e := map[string]any{"name": d.Name, "kind": d.Kind, "usedAs": d.Role}
if d.Kernel != "" {
e["kernelName"] = d.Kernel
}
if d.MAC != "" {
e["mac"] = d.MAC
}
if d.Custom != "" {
e["deviceName"] = d.Custom
}
out = append(out, e)
}
return out, nil
},
},
{
name: "list_dhcp_leases", scope: scopeRead, schema: emptySchema,
description: "The DHCP clients currently holding a lease: address, MAC, hostname, LAN, when the lease ends.",
run: func(a *app, _ json.RawMessage) (any, error) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
return nil, err
}
var out []map[string]any
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
out = append(out, map[string]any{"address": l.Address, "mac": l.MAC, "hostname": l.Hostname, "lan": l.LAN, "expires": l.Expires})
}
return out, nil
},
},
{
name: "get_update_status", scope: scopeRead, schema: emptySchema,
description: "The installed release (version, schema) and the state of the last update run.",
run: func(a *app, _ json.RawMessage) (any, error) {
st := a.sys.UpdateStatus()
return map[string]any{"installed": a.sys.installedRelease(), "running": st.Running, "lastResult": st.Result, "log": st.Log}, nil
},
},
{
name: "get_apply_log", scope: scopeRead, schema: emptySchema,
description: "The log of the last apply (build and activation) and its result.",
run: func(a *app, _ json.RawMessage) (any, error) {
st := a.sys.Status()
return map[string]any{"applying": st.Applying, "result": st.ApplyResult, "log": st.Log}, nil
},
},
logsTool,
{
name: "check_settings", scope: scopeRead,
schema: emptySchema,
description: "Evaluates the saved settings the way an apply would (the router's assertions and the option types) without building or activating anything. Takes about a minute; if it is still running, call again to get the result. Reports whether the result is for the current saved settings.",
run: func(a *app, _ json.RawMessage) (any, error) { return a.checkSettings() },
},
{
name: "update_settings", scope: scopeChange,
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"etag": map[string]any{"type": "string", "description": "The etag from get_settings: the change is refused if the settings changed since."},
"patch": map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386) on the settings: objects merge, a value replaces, null removes a key, arrays are replaced whole."},
},
"required": []string{"etag", "patch"},
},
description: "Edit the saved settings with a JSON merge patch. The settings are saved but not applied: a person applies them in the web UI. Not allowed: ssh keys, the schema version, the state version, and file paths outside the router's state directory. Run check_settings afterwards.",
run: func(a *app, args json.RawMessage) (any, error) { return a.updateSettings(args) },
},
{
name: "discard_changes", scope: scopeChange, schema: emptySchema,
description: "Throw away the unapplied changes: the settings go back to the confirmed ones.",
run: func(a *app, _ json.RawMessage) (any, error) {
if err := a.sys.Discard(); err != nil {
return nil, err
}
return "The settings are the confirmed ones again.", nil
},
},
}
// mergePatch applies a JSON merge patch (RFC 7386).
func mergePatch(target, patch any) any {
p, ok := patch.(map[string]any)
if !ok {
return patch
}
t, ok := target.(map[string]any)
if !ok {
t = map[string]any{}
}
for k, v := range p {
if v == nil {
delete(t, k)
} else {
t[k] = mergePatch(t[k], v)
}
}
return t
}
// checkMCPSettings refuses what an assistant may not change.
func (s System) checkMCPSettings(old, next Settings) error {
for k := range next {
if k != "version" && k != "system" && k != "router" {
return fmt.Errorf("unknown top-level key %q (only system and router)", k)
}
}
same := func(a, b any) bool { x, _ := json.Marshal(a); y, _ := json.Marshal(b); return string(x) == string(y) }
if !same(old["version"], next["version"]) {
return errors.New("`version` (the settings schema) cannot be changed")
}
oldSys, newSys := obj(Settings{"system": old["system"]}, "system"), obj(Settings{"system": next["system"]}, "system")
for _, k := range []string{"sshKeys", "stateVersion"} {
if !same(oldSys[k], newSys[k]) {
return fmt.Errorf("`system.%s` cannot be changed through this interface", k)
}
}
for k := range newSys {
if k != "hostName" && k != "timeZone" && !same(oldSys[k], newSys[k]) {
return fmt.Errorf("`system.%s` cannot be changed through this interface (only hostName and timeZone)", k)
}
}
var walk func(path string, v any) error
walk = func(path string, v any) error {
switch x := v.(type) {
case map[string]any:
for k, e := range x {
if str, ok := e.(string); ok && strings.HasSuffix(k, "File") {
if !strings.HasPrefix(str, s.StateDir+"/") || strings.Contains(str, "..") {
return fmt.Errorf("%s.%s: files must be under %s (the person who runs the router puts secrets there)", path, k, s.StateDir)
}
}
if err := walk(path+"."+k, e); err != nil {
return err
}
}
case []any:
for i, e := range x {
if err := walk(fmt.Sprintf("%s[%d]", path, i), e); err != nil {
return err
}
}
}
return nil
}
return walk("router", next["router"])
}
func (a *app) updateSettings(args json.RawMessage) (any, error) {
var in struct {
Etag string `json:"etag"`
Patch any `json:"patch"`
}
if err := json.Unmarshal(args, &in); err != nil || in.Patch == nil {
return nil, errors.New("give `etag` and `patch` (an object)")
}
if _, ok := in.Patch.(map[string]any); !ok {
return nil, errors.New("`patch` must be an object")
}
a.settingsMu.Lock()
defer a.settingsMu.Unlock()
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, err
}
if in.Etag != etag(data) {
return nil, errStale
}
old, err := parseSettings(data)
if err != nil {
return nil, err
}
next, err := parseSettings(data) // a copy to patch
if err != nil {
return nil, err
}
patched, _ := mergePatch(map[string]any(next), in.Patch).(map[string]any)
next = Settings(patched)
if err := a.sys.checkMCPSettings(old, next); err != nil {
return nil, err
}
if err := next.save(a.sys.settingsPath()); err != nil {
return nil, err
}
after, _ := os.ReadFile(a.sys.settingsPath())
return map[string]any{"saved": true, "applied": false, "etag": etag(after),
"changes": diffLines(pretty(data), pretty(after)),
"next": "Run check_settings; a person applies the changes in the web UI."}, nil
}
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// rpc posts a JSON-RPC request to /mcp.
func rpc(h http.Handler, token, method string, params any) (*httptest.ResponseRecorder, map[string]any) {
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(string(body)))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec, out
}
// call runs a tool and returns its text and whether it failed.
func call(t *testing.T, h http.Handler, token, tool string, args any) (string, bool) {
t.Helper()
rec, out := rpc(h, token, "tools/call", map[string]any{"name": tool, "arguments": args})
if rec.Code != 200 {
t.Fatalf("%s: HTTP %d", tool, rec.Code)
}
if out["error"] != nil {
return out["error"].(map[string]any)["message"].(string), true
}
res := out["result"].(map[string]any)
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
failed, _ := res["isError"].(bool)
return text, failed
}
func TestMCPAuth(t *testing.T) {
a, _ := newTestApp(t)
h := a.routes()
if rec, _ := rpc(h, "", "ping", nil); rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("no token: %d", rec.Code)
}
if rec, _ := rpc(h, "rtr_nonsense", "ping", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong token: %d", rec.Code)
}
if rec := do(h, "GET", "/mcp", nil, nil); rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET: %d", rec.Code)
}
_, secret, err := a.tokens.Create("test", "read")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`))
req.Header.Set("Authorization", "Bearer "+secret)
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)
}
// A session cookie is no token.
cookie := login(t, h)
req = httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`))
req.AddCookie(cookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("session cookie: %d", rec.Code)
}
}
func TestMCPProtocol(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
_, read, _ := a.tokens.Create("reader", "read")
_, change, _ := a.tokens.Create("writer", "change")
rec, out := rpc(h, read, "initialize", map[string]any{"protocolVersion": "2025-03-26"})
if rec.Code != 200 || out["result"].(map[string]any)["protocolVersion"] != "2025-03-26" {
t.Fatalf("initialize: %s", rec.Body)
}
// A notification gets no body.
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`))
req.Header.Set("Authorization", "Bearer "+read)
nrec := httptest.NewRecorder()
h.ServeHTTP(nrec, req)
if nrec.Code != http.StatusAccepted || nrec.Body.Len() != 0 {
t.Fatalf("notification: %d %q", nrec.Code, nrec.Body)
}
if _, out := rpc(h, read, "nonsense", nil); out["error"] == nil {
t.Fatal("unknown method accepted")
}
names := func(token string) string {
_, out := rpc(h, token, "tools/list", nil)
var n []string
for _, t := range out["result"].(map[string]any)["tools"].([]any) {
n = append(n, t.(map[string]any)["name"].(string))
}
return strings.Join(n, " ")
}
if got := names(read); strings.Contains(got, "update_settings") || !strings.Contains(got, "get_settings") {
t.Fatalf("read tools: %s", got)
}
if got := names(change); !strings.Contains(got, "update_settings") || !strings.Contains(got, "discard_changes") {
t.Fatalf("change tools: %s", got)
}
if text, failed := call(t, h, read, "update_settings", map[string]any{}); !failed || !strings.Contains(text, "unknown tool") {
t.Fatalf("a read token edited settings: %s", text)
}
// Reading.
text, failed := call(t, h, read, "get_settings", nil)
if failed {
t.Fatal(text)
}
var got struct {
Etag string
Settings map[string]any
}
if err := json.Unmarshal([]byte(text), &got); err != nil || got.Etag == "" || got.Settings["router"] == nil {
t.Fatalf("get_settings: %v %s", err, text)
}
if _, failed := call(t, h, read, "get_status", nil); failed {
t.Fatal("get_status failed")
}
if text, failed := call(t, h, read, "list_interfaces", nil); failed || !strings.Contains(text, "WAN") {
t.Fatalf("list_interfaces: %s", text)
}
if _, failed := call(t, h, read, "list_dhcp_leases", nil); failed {
t.Fatal("list_dhcp_leases failed")
}
// Editing: a patch merges, a stale etag is refused, nothing is applied.
patch := map[string]any{"etag": got.Etag, "patch": map[string]any{"system": map[string]any{"hostName": "assistant-was-here"}}}
text, failed = call(t, h, change, "update_settings", patch)
if failed || !strings.Contains(text, "assistant-was-here") || !strings.Contains(text, `"applied": false`) {
t.Fatalf("update_settings: %s", text)
}
if data, _ := os.ReadFile(sys.settingsPath()); !strings.Contains(string(data), "assistant-was-here") {
t.Fatal("not saved")
}
if text, failed := call(t, h, change, "update_settings", patch); !failed || !strings.Contains(text, "etag") {
t.Fatalf("a stale etag was accepted: %s", text)
}
// A change shows up for review (the confirmed copy is the sample).
os.WriteFile(sys.confirmedPath(), []byte(sample), 0o600)
if text, _ := call(t, h, read, "get_pending_changes", nil); !strings.Contains(text, "+") || !strings.Contains(text, "assistant-was-here") {
t.Fatalf("pending: %s", text)
}
if text, failed := call(t, h, change, "discard_changes", nil); failed {
t.Fatal(text)
}
if data, _ := os.ReadFile(sys.settingsPath()); strings.Contains(string(data), "assistant-was-here") {
t.Fatal("discard did not restore the settings")
}
// Every call was logged, with the token's name.
audit := sys.RecentAudit(50)
if len(audit) < 5 || audit[0].Tool != "discard_changes" || audit[0].Token != "writer" {
t.Fatalf("audit: %+v", audit)
}
}
func TestMCPEditLimits(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
_, change, _ := a.tokens.Create("writer", "change")
etagNow := func() string {
data, _ := os.ReadFile(sys.settingsPath())
return etag(data)
}
for name, patch := range map[string]map[string]any{
"ssh keys": {"system": map[string]any{"sshKeys": []string{"ssh-ed25519 AAAA attacker"}}},
"state version": {"system": map[string]any{"stateVersion": "20.09"}},
"schema": {"version": 99},
"top level": {"users": map[string]any{"x": 1}},
"secret path": {"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": "/etc/shadow"}}}}},
"path escape": {"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": sys.StateDir + "/../etc/shadow"}}}}},
"nested file": {"router": map[string]any{"wireguard": map[string]any{"wg0": map[string]any{"privateKeyFile": "/root/.ssh/id_rsa"}}}},
} {
before, _ := os.ReadFile(sys.settingsPath())
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": patch}); !failed {
t.Errorf("%s accepted: %s", name, text)
}
if after, _ := os.ReadFile(sys.settingsPath()); string(after) != string(before) {
t.Errorf("%s changed the file", name)
}
}
// Files in the state directory, and null to remove a key, are fine.
ok := map[string]any{"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": map[string]any{"passwordFile": sys.StateDir + "/pppoe-password"}}}}}
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": ok}); failed {
t.Fatalf("a file in the state directory refused: %s", text)
}
rm := map[string]any{"router": map[string]any{"wan": map[string]any{"ipv4": map[string]any{"pppoe": nil}}}}
if text, failed := call(t, h, change, "update_settings", map[string]any{"etag": etagNow(), "patch": rm}); failed || strings.Contains(text, "pppoe-password\"") && !strings.Contains(text, "-") {
t.Fatalf("null did not remove: %s", text)
}
}
func TestMergePatchAndDiff(t *testing.T) {
got, _ := json.Marshal(mergePatch(
map[string]any{"a": map[string]any{"b": 1, "c": 2}, "d": []any{1, 2}, "e": "x"},
map[string]any{"a": map[string]any{"b": nil, "z": 3}, "d": []any{9}}))
if string(got) != `{"a":{"c":2,"z":3},"d":[9],"e":"x"}` {
t.Fatalf("merge: %s", got)
}
if diffLines("a\nb", "a\nb") != "" {
t.Fatal("equal texts differ")
}
d := diffLines("1\n2\n3\n4\n5\n6\n7\n8\n9", "1\n2\n3\nfour\n5\n6\n7\n8\n9")
if !strings.Contains(d, "- 4\n+ four\n") || !strings.Contains(d, "…") {
t.Fatalf("diff:\n%s", d)
}
}
func TestTokensPage(t *testing.T) {
a, _ := newTestApp(t)
h := a.routes()
cookie := login(t, h)
token := csrfFrom(t, do(h, "GET", "/tokens", nil, cookie).Body.String())
rec := do(h, "POST", "/tokens", map[string][]string{"csrf": {token}, "name": {"laptop"}, "scope": {"read"}}, cookie)
body := rec.Body.String()
i := strings.Index(body, "rtr_")
if rec.Code != 200 || i < 0 {
t.Fatalf("create: %d\n%s", rec.Code, body)
}
secret := body[i : i+4+43]
if _, ok := a.tokens.Lookup(secret); !ok {
t.Fatal("the shown token does not work")
}
// The secret is not stored or shown again.
if data, _ := os.ReadFile(a.sys.tokensPath()); strings.Contains(string(data), secret) {
t.Fatal("the secret is stored")
}
if strings.Contains(do(h, "GET", "/tokens", nil, cookie).Body.String(), secret) {
t.Fatal("the secret is shown again")
}
toks := a.tokens.List()
do(h, "POST", "/tokens/revoke", map[string][]string{"csrf": {token}, "id": {toks[0].ID}}, cookie)
if _, ok := a.tokens.Lookup(secret); ok {
t.Fatal("a revoked token works")
}
if rec := do(h, "GET", "/changes", nil, cookie); rec.Code != 200 {
t.Fatalf("changes page: %d", rec.Code)
}
}
+6
View File
@@ -276,6 +276,10 @@ type Device struct {
Kind string // "physical" or "VLAN"
Role string // "WAN", "LAN lan", or ""
Removable bool // a VLAN defined in the UI that nothing uses
// Set by the app from the machine and the settings.
Kernel string // physical: the name the kernel gives the card now
MAC string // physical: of the card, when it is on this machine
Custom string // VLAN: the name chosen for its device
}
// devices lists the physical interfaces plus the VLAN devices the settings
@@ -377,6 +381,7 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
pppoe["vlan"] = wanVLAN
} else {
delete(pppoe, "vlan")
delete(pppoe, "vlanName")
}
if f.WAN.PPPoEPassword != "" {
pppoe["passwordFile"] = passwordFile
@@ -431,6 +436,7 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
l["vlan"] = lanVLAN
} else {
delete(l, "vlan")
delete(l, "deviceName")
}
l["internet"] = lf.Internet
if lf.IPv6PD {
-8
View File
@@ -1,11 +1,3 @@
// Menus close on a click elsewhere; forms with data-confirm ask first.
// Close an open menu when the click is somewhere else.
document.addEventListener("click", function (e) {
document.querySelectorAll("nav details[open]").forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute("open");
});
});
// Ask before a form marked data-confirm is sent.
document.addEventListener("submit", function (e) {
var q = e.target.getAttribute("data-confirm");
+101 -62
View File
@@ -1,37 +1,55 @@
/* Minimal, neutral, system fonts. Colours are tokens; dark follows the system. */
/* Left sidebar, panels with a header bar. Light and dark: follows the system
unless the page says data-theme="light" or "dark" (Advanced page). */
:root {
--fg: #c8e6c9; --muted: #6f8f72; --bg: #0a0c0a; --surface: #0d110d; --line: #1f3322; --field: #0a0c0a;
--accent: #4ade80; --on-accent: #04140a; --warn: #2b2a10; --bad: #ff6b6b; --ok: #10281a; --good: #4ade80;
--radius: 0; --shadow: none;
--mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
--fg: #1f2430; --muted: #5d6675; --bg: #eef0f3; --surface: #fff; --field: #fff; --line: #d5d9e0; --head: #e4e8ee; --zebra: #f6f8fa;
--accent: #1d6fd8; --accent-hover: #1859b0; --on-accent: #fff;
--side: #232a35; --side-fg: #c6cdd9; --side-muted: #7d889a; --side-hover: #323b49; --side-active: #1d6fd8;
--warn: #fff3cd; --warnline: #e0a100; --bad: #c62828; --ok: #dff3e4; --good: #1b7a3a;
--code: #eceff3; --switch: #b8c0cc; --tick: #8a94a5; --scroll: #b9c0cc;
--s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; --s5: #0b7285; --s6: #862e9c; --s7: #5c940d; --s8: #a61e4d; --s9: #5f3dc4;
--radius: 4px; --shadow: 0 1px 2px rgba(16, 24, 40, .06);
color-scheme: light;
}
* { box-sizing: border-box; }
html { color-scheme: dark; scrollbar-color: #2e4a33 var(--bg); scrollbar-width: thin; }
* { scrollbar-color: #2e4a33 var(--bg); scrollbar-width: thin; }
body { margin: 0; font: 13.5px/1.55 var(--mono); color: var(--fg); background: var(--bg); -webkit-font-smoothing: antialiased; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--fg: #e4e8ee; --muted: #9aa4b5; --bg: #181c23; --surface: #212732; --field: #181c23; --line: #333b48; --head: #2a313d; --zebra: rgba(255, 255, 255, .03);
--accent: #6ea8fe; --accent-hover: #8dbaff; --on-accent: #0d1b33;
--side: #12151b; --side-fg: #c3cad6; --side-muted: #6c7688; --side-hover: #202631; --side-active: #6ea8fe;
--warn: #3b341a; --warnline: #f0b429; --bad: #ff8a80; --ok: #1c3527; --good: #5fd38d;
--code: #2c3441; --switch: #434d5e; --tick: #5b667a; --scroll: #3d4757;
--s0: #8ba4ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; --s5: #66d9e8; --s6: #e599f7; --s7: #c0eb75; --s8: #faa2c1; --s9: #b197fc;
--shadow: none; color-scheme: dark;
}
}
:root[data-theme="dark"] {
--fg: #e4e8ee; --muted: #9aa4b5; --bg: #181c23; --surface: #212732; --field: #181c23; --line: #333b48; --head: #2a313d; --zebra: rgba(255, 255, 255, .03);
--accent: #6ea8fe; --accent-hover: #8dbaff; --on-accent: #0d1b33;
--side: #12151b; --side-fg: #c3cad6; --side-muted: #6c7688; --side-hover: #202631; --side-active: #6ea8fe;
--warn: #3b341a; --warnline: #f0b429; --bad: #ff8a80; --ok: #1c3527; --good: #5fd38d;
--code: #2c3441; --switch: #434d5e; --tick: #5b667a; --scroll: #3d4757;
--s0: #8ba4ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; --s5: #66d9e8; --s6: #e599f7; --s7: #c0eb75; --s8: #faa2c1; --s9: #b197fc;
--shadow: none; color-scheme: dark;
}
* { box-sizing: border-box; scrollbar-color: var(--scroll) transparent; scrollbar-width: thin; }
body { margin: 0; padding-left: 15rem; font: 14.5px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); -webkit-font-smoothing: antialiased; }
a { color: var(--accent); text-underline-offset: 2px; }
header { position: sticky; top: 0; z-index: 20; display: flex; gap: 2rem; align-items: center; padding: .6rem 1.25rem;
background: var(--bg); border-bottom: 1px solid var(--accent); flex-wrap: wrap; }
header > strong { font-weight: 500; }
header > strong::before { content: "> "; color: var(--accent); }
header > strong::after { content: "_"; color: var(--accent); }
nav { display: flex; gap: .25rem; align-items: center; flex-wrap: wrap; }
nav > a, nav summary, nav button.link { padding: .3rem .65rem; color: var(--fg); text-decoration: none; cursor: pointer; }
nav > a:hover, nav summary:hover, nav details[open] > summary, nav button.link:hover { background: color-mix(in srgb, var(--fg) 7%, transparent); }
nav form { display: inline; }
nav details { position: relative; margin: 0; }
nav summary { list-style: none; }
nav summary::-webkit-details-marker { display: none; }
nav summary::after { content: ""; display: inline-block; width: .35rem; height: .35rem; margin: 0 0 .15rem .45rem; border: solid currentColor; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); opacity: .55; }
nav details > div { position: absolute; z-index: 30; top: 2.2rem; left: 0; min-width: 12rem; display: flex; flex-direction: column; gap: 1px;
padding: .35rem; background: var(--surface); border: 1px solid var(--accent); }
nav details > div a { padding: .4rem .65rem; border-radius: 0; color: var(--fg); text-decoration: none; }
nav details > div a:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); }
main { max-width: 70rem; margin: 0 auto; padding: 1.5rem 1.25rem 3rem; }
h1 { font-size: 1.15rem; font-weight: 500; text-transform: uppercase; letter-spacing: .12em; margin: .25rem 0 1.25rem; }
h1::before { content: "# "; color: var(--accent); }
h2, legend { font-size: .85rem; font-weight: 500; text-transform: uppercase; letter-spacing: .08em; color: var(--accent); }
h2 { margin: 0 0 .6rem; }
/* Sidebar */
header { position: fixed; z-index: 20; left: 0; top: 0; bottom: 0; width: 15rem; display: flex; flex-direction: column; gap: 1rem; padding: 1rem .6rem; background: var(--side); overflow-y: auto; }
header > strong { padding: .2rem .7rem; font-size: 1.1rem; font-weight: 700; color: #fff; }
nav { display: flex; flex-direction: column; gap: 1px; }
nav > a, nav .group a, nav button.link { display: block; width: 100%; padding: .38rem .7rem; border-radius: var(--radius); color: var(--side-fg); text-decoration: none; text-align: left; }
nav .group a { padding-left: 1.4rem; }
nav a:hover, nav button.link:hover { background: var(--side-hover); color: #fff; }
nav a[aria-current] { background: var(--side-active); color: var(--on-accent); }
nav .group { margin: .8rem 0 0; }
nav .group > span { display: block; padding: 0 .7rem .25rem; font-size: .7rem; text-transform: uppercase; letter-spacing: .08em; color: var(--side-muted); }
nav form { margin-top: 1rem; }
nav button.link { background: none; border: 0; padding: .38rem .7rem; font-weight: 400; cursor: pointer; }
main { max-width: 74rem; padding: 1.5rem 2rem 3rem; }
h1 { font-size: 1.45rem; font-weight: 600; letter-spacing: -.01em; margin: .25rem 0 1.25rem; }
h2 { font-size: .95rem; font-weight: 600; margin: 0 0 .6rem; }
section { margin: 0 0 1.5rem; }
section.busy, section.pending { padding: .75rem 1rem; border-radius: var(--radius); background: var(--warn); }
.flash, .error { padding: .6rem .9rem; border-radius: var(--radius); margin: 0 0 1rem; }
@@ -43,61 +61,82 @@ section.busy, section.pending { padding: .75rem 1rem; border-radius: var(--radiu
dl { display: grid; grid-template-columns: max-content 1fr; gap: .3rem 1.25rem; }
dt { color: var(--muted); }
dd { margin: 0; overflow-wrap: anywhere; }
fieldset { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); margin: 0 0 1rem; padding: 1rem 1.15rem; overflow-x: auto; min-width: 0; }
legend { float: left; width: 100%; padding: 0; margin: 0 0 .7rem; }
/* Panels: a header bar (the legend) over a padded body */
fieldset { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); margin: 0 0 1rem; padding: 0 0 1rem; min-width: 0; overflow: hidden; }
legend { float: left; width: 100%; margin: 0 0 1rem; padding: .6rem 1rem; font-size: .95rem; font-weight: 600; background: var(--head); border-bottom: 1px solid var(--line); }
legend ~ * { clear: both; }
fieldset > :not(legend):not(input[type=hidden]) { margin-left: 1rem; margin-right: 1rem; }
fieldset > .scroll { margin-left: 0; margin-right: 0; padding: 0 1rem; overflow-x: auto; }
/* Fields: a caption above the input */
label { display: inline-flex; flex-direction: column; gap: .2rem; margin: 0 1rem .6rem 0; font-size: .85em; font-weight: 500; color: var(--muted); }
input, select, textarea, button { font: inherit; color: var(--fg); background: var(--field); border: 1px solid var(--line); border-radius: 0; padding: .4rem .6rem; }
input, select, textarea, button { font: inherit; color: var(--fg); background: var(--field); border: 1px solid var(--line); border-radius: var(--radius); padding: .4rem .6rem; }
input:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--fg) 30%, var(--line)); }
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible, summary:focus-visible { outline: none; box-shadow: 0 0 0 1px var(--accent); border-color: var(--accent); }
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 70%, transparent); }
textarea { width: 100%; font-size: 13px; }
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent); }
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 65%, transparent); }
textarea { width: 100%; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; }
.narrow { max-width: 24rem; display: flex; flex-direction: column; gap: .8rem; }
.narrow label { margin: 0; }
/* Ticks: a switch in front of its text; a plain small box inside tables */
/* Ticks: a switch in front of its text; a plain box inside tables */
label:has(> input[type=checkbox]) { flex-direction: row; align-items: center; gap: .6rem; font-size: 1em; font-weight: 400; color: var(--fg); margin: 0 1.25rem .5rem 0; }
fieldset > label:has(> input[type=checkbox]), form > label:has(> input[type=checkbox]) { display: flex; }
input[type=checkbox] { appearance: none; flex: none; width: 2.4rem; height: 1.2rem; padding: 0; margin: 0; border-radius: 0; position: relative; cursor: pointer;
background: #1c2a1e; border: 0; transition: background .15s; vertical-align: middle; }
input[type=checkbox]::after { content: ""; position: absolute; top: .15rem; left: .15rem; width: .9rem; height: .9rem; border-radius: 0; background: var(--muted); transition: transform .15s; }
input[type=checkbox]:checked { background: #14532d; }
input[type=checkbox]:checked::after { transform: translateX(1.2rem); background: var(--accent); }
td > input[type=checkbox], th > input[type=checkbox], p > label > input[type=checkbox] { width: 1.05rem; height: 1.05rem; border-radius: 0; border: 1.5px solid color-mix(in srgb, var(--fg) 40%, transparent); background: var(--field); }
input[type=checkbox] { appearance: none; flex: none; position: relative; width: 2.1rem; height: 1.2rem; padding: 0; margin: 0; border: 0; border-radius: 1rem; background: var(--switch); cursor: pointer; transition: background .15s; vertical-align: middle; }
input[type=checkbox]::after { content: ""; position: absolute; top: .15rem; left: .15rem; width: .9rem; height: .9rem; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .3); transition: transform .15s; }
input[type=checkbox]:checked { background: var(--accent); }
input[type=checkbox]:checked::after { transform: translateX(.9rem); }
td > input[type=checkbox], th > input[type=checkbox], p > label > input[type=checkbox] { width: 1.05rem; height: 1.05rem; border-radius: 3px; border: 1.5px solid var(--tick); background: var(--field); }
td > input[type=checkbox]::after, th > input[type=checkbox]::after, p > label > input[type=checkbox]::after { display: none; }
td > input[type=checkbox]:checked, th > input[type=checkbox]:checked, p > label > input[type=checkbox]:checked { background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-6.5' fill='none' stroke='%2304140a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 90% no-repeat; border-color: var(--accent); }
td > input[type=checkbox]:checked, th > input[type=checkbox]:checked, p > label > input[type=checkbox]:checked { border-color: var(--accent); background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-6.5' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 90% no-repeat; }
td:has(> input[type=checkbox]) { text-align: center; }
/* Buttons */
button { background: transparent; color: var(--accent); border-color: var(--accent); cursor: pointer; text-transform: uppercase; letter-spacing: .06em; padding: .4rem 1.05rem; }
button:hover { background: var(--accent); color: var(--on-accent); }
button.secondary { border-color: var(--line); color: var(--fg); }
button.secondary:hover { border-color: var(--accent); background: transparent; color: var(--accent); }
button.link { background: none; border: none; color: var(--fg); padding: .3rem .65rem; text-transform: none; letter-spacing: 0; }
button.link:hover { background: none; }
button { background: var(--accent); color: var(--on-accent); border-color: var(--accent); cursor: pointer; font-weight: 600; padding: .42rem 1.05rem; }
button:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
button.secondary { background: var(--surface); color: var(--fg); border-color: var(--line); }
button.secondary:hover { background: var(--zebra); border-color: var(--accent); }
button.link { background: none; border: 0; padding: 0; color: var(--accent); font-weight: 400; text-decoration: underline; }
button.link:hover { background: none; color: var(--accent-hover); }
.default { position: absolute; left: -9999px; }
.actions { display: flex; gap: .75rem; flex-wrap: wrap; align-items: center; }
/* Tables */
table { border-collapse: collapse; }
th { text-align: left; font-weight: 500; color: var(--accent); font-size: .8em; text-transform: uppercase; letter-spacing: .03em; padding: 0 .4rem .3rem; white-space: nowrap; }
td { padding: .2rem .4rem; }
pre.log { background: color-mix(in srgb, var(--fg) 5%, transparent); padding: .85rem 1rem; border-radius: var(--radius); overflow-x: auto; font-size: 12.5px; max-height: 30rem; }
code { font-size: .88em; overflow-wrap: anywhere; background: color-mix(in srgb, var(--fg) 7%, transparent); padding: .1em .35em; border-radius: 0; }
th { text-align: left; font-size: .82rem; font-weight: 600; color: var(--muted); padding: 0 .45rem .35rem; white-space: nowrap; border-bottom: 1px solid var(--line); }
td { padding: .3rem .45rem; }
table tr:nth-child(even) td { background: var(--zebra); }
pre.log { background: var(--code); padding: .85rem 1rem; border-radius: var(--radius); overflow-x: auto; font-size: 12.5px; max-height: 30rem; }
code { font-size: .88em; overflow-wrap: anywhere; background: var(--code); padding: .1em .35em; border-radius: 3px; }
pre code { background: none; padding: 0; }
.muted { color: var(--muted); }
.bad { color: var(--bad); }
.ok { color: var(--good); }
:root { --s0: #8ba4ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; --s5: #66d9e8; --s6: #e599f7; --s7: #c0eb75; --s8: #faa2c1; --s9: #b197fc; }
body.bare { padding-left: 0; }
.bare header { position: static; width: auto; flex-direction: row; background: none; padding: 1rem 1.25rem; }
.bare header > strong { color: var(--fg); }
.bare main { margin: 0 auto; }
@media (max-width: 50rem) {
body { padding-left: 0; }
header { position: static; width: auto; flex-direction: row; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; padding: .6rem 1rem; }
nav { flex-direction: row; flex-wrap: wrap; align-items: center; gap: .1rem .25rem; }
nav > a, nav .group a, nav button.link { width: auto; padding: .3rem .55rem; }
nav .group { display: contents; }
nav .group > span, nav form { display: none; }
nav form:has(button) { display: block; margin: 0; }
main { padding: 1rem; }
}
.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
.ranges a { padding: .2rem .75rem; border: 1px solid var(--line); border-radius: 0; text-decoration: none; color: var(--fg); background: var(--surface); }
.ranges a:hover, .ranges a.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.ranges a { padding: .25rem .8rem; border: 1px solid var(--line); border-radius: var(--radius); text-decoration: none; color: var(--fg); background: var(--surface); }
.ranges a:hover { border-color: var(--accent); color: var(--accent); }
.ranges a.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1rem; margin: 0 0 1.25rem; }
.card { background: var(--surface); border: 1px solid var(--line); border-left-width: 4px; border-radius: var(--radius); box-shadow: var(--shadow); padding: .85rem 1.1rem; margin: 0; }
.card.up { border-left-color: var(--good); }
.card.degraded { border-left-color: #e8a600; }
.card.down { border-left-color: var(--bad); }
.card { background: var(--surface); border: 1px solid var(--line); border-top: 3px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: .85rem 1.1rem; margin: 0; }
.card.up { border-top-color: var(--good); }
.card.degraded { border-top-color: var(--warnline); }
.card.down { border-top-color: var(--bad); }
.card .state { font-weight: 600; margin: 0 0 .5rem; }
.card dl { margin: 0; font-size: .92em; }
.events { list-style: none; margin: 0; padding: 0; font-size: .9em; }
+16
View File
@@ -10,6 +10,22 @@
</form>
</section>
<section>
<h2>Appearance</h2>
<form method="post" action="/prefs" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Theme
<select name="theme">
<option value="" {{if eq .Data.Theme ""}}selected{{end}}>Automatic (follow the browser or system)</option>
<option value="light" {{if eq .Data.Theme "light"}}selected{{end}}>Light</option>
<option value="dark" {{if eq .Data.Theme "dark"}}selected{{end}}>Dark</option>
</select>
</label>
<p class="hint">Applies to everyone using this web UI, right away.</p>
<button>Save</button>
</form>
</section>
<section>
<h2>Dashboard</h2>
<form method="post" action="/prefs" class="narrow">
+15
View File
@@ -0,0 +1,15 @@
{{define "content"}}
{{with .Data}}
{{if .Diff}}
<p>The saved settings differ from the confirmed (running) ones: <code>-</code> lines go away, <code>+</code> lines are new.
Nothing is applied until you press Apply.</p>
<pre class="log">{{.Diff}}</pre>
<div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button>Apply</button></form>
<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="secondary">Discard changes</button></form>
</div>
{{else}}
<p>No changes: the saved settings are the confirmed ones.</p>
{{end}}
{{end}}
{{end}}
@@ -34,6 +34,7 @@
<dl>
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
{{if $sys.Kernel}}<dt>Kernel</dt><dd>{{$sys.Kernel}}{{if $sys.KernelNext}} <span class="bad">reboot to run {{$sys.KernelNext}}</span> <a href="/advanced">Reboot</a>{{end}}</dd>{{end}}
{{if $sys.BootPending}}<dt>Reboot</dt><dd><span class="bad">a new configuration starts at the next reboot</span> <a href="/advanced">Reboot</a></dd>{{end}}
<dt>Load</dt><dd>{{$sys.Load}}</dd>
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
+2 -2
View File
@@ -49,7 +49,7 @@
<td><input name="rule.{{$i}}.sources" value="{{$r.Sources}}" size="16" spellcheck="false" placeholder="any"></td>
<td><input name="rule.{{$i}}.sourceports" value="{{$r.SourcePorts}}" size="8" placeholder="any"></td>
<td><select name="rule.{{$i}}.to"><option value=""></option>
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
{{range $.Data.ToZones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
</select></td>
<td><input name="rule.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="whole zone"></td>
<td><input name="rule.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="all"></td>
@@ -69,7 +69,7 @@
to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.
From <code>wan</code> (the internet) a rule must name the address inside, the protocol and the ports, and only lets
traffic through; a port forward is set up below. <strong>Forwarded only</strong> limits a rule to connections that a
port forward translated. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
port forward translated. To zone <code>local</code> is the router itself (SSH, the web UI): no address needed, but protocol and ports are. A port forward on the same port wins. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
</fieldset>
<fieldset>
+20 -19
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en"{{with .Theme}} data-theme="{{.}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,28 +7,29 @@
<title>{{.Title}} · router</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<body{{if not .LoggedIn}} class="bare"{{end}}>
<header>
<strong>router</strong>
{{if .LoggedIn}}
<nav>
<a href="/">Overview</a>
<details name="menu"><summary>Network</summary><div>
<a href="/network">Interfaces</a>
<a href="/dhcp">DHCP</a>
<a href="/dns">DNS</a>
<a href="/wireguard">WireGuard</a>
<a href="/firewall">Firewall</a>
</div></details>
<details name="menu"><summary>Services</summary><div>
<a href="/ddns">Dynamic DNS</a>
<a href="/dnsproviders">DNS providers</a>
<a href="/certificate">Certificate</a>
</div></details>
<details name="menu"><summary>System</summary><div>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
</div></details>
<a href="/"{{if eq .Title "Overview"}} aria-current="page"{{end}}>Overview</a>
<div class="group"><span>Network</span>
<a href="/network"{{if eq .Title "Network"}} aria-current="page"{{end}}>Interfaces</a>
<a href="/dhcp"{{if eq .Title "DHCP"}} aria-current="page"{{end}}>DHCP</a>
<a href="/dns"{{if eq .Title "DNS"}} aria-current="page"{{end}}>DNS</a>
<a href="/wireguard"{{if eq .Title "WireGuard"}} aria-current="page"{{end}}>WireGuard</a>
<a href="/firewall"{{if eq .Title "Firewall"}} aria-current="page"{{end}}>Firewall</a>
</div>
<div class="group"><span>Services</span>
<a href="/ddns"{{if eq .Title "Dynamic DNS"}} aria-current="page"{{end}}>Dynamic DNS</a>
<a href="/dnsproviders"{{if eq .Title "DNS providers"}} aria-current="page"{{end}}>DNS providers</a>
<a href="/certificate"{{if eq .Title "Certificate"}} aria-current="page"{{end}}>Certificate</a>
</div>
<div class="group"><span>System</span>
<a href="/update"{{if eq .Title "Update"}} aria-current="page"{{end}}>Update</a>
<a href="/tokens"{{if eq .Title "API access"}} aria-current="page"{{end}}>API access</a>
<a href="/advanced"{{if eq .Title "Advanced"}} aria-current="page"{{end}}>Advanced</a>
</div>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
</nav>
{{end}}
+41 -14
View File
@@ -1,23 +1,50 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<form method="post" action="/vlan">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>Interfaces</legend>
<fieldset>
<legend>Interfaces</legend>
<form method="post" action="/interfaces">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="rows" value="{{len .Devices}}">
<button class="default" tabindex="-1" aria-hidden="true">Save names</button>
<div class="scroll">
<table>
<tr><th>Interface</th><th>Kind</th><th>Used as</th><th></th></tr>
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td>
<td>{{if .Removable}}<button name="remove" value="{{.Name}}" class="link">remove</button>{{end}}</td></tr>{{end}}
<tr><th>Interface</th><th>MAC address</th><th title="The name Linux gives it: the web UI, the firewall and the logs use it">Name</th><th>Kind</th><th>Used as</th><th></th></tr>
{{range $i, $d := .Devices}}<tr>
<td>{{if $d.Kernel}}{{$d.Kernel}}{{else}}{{$d.Name}}{{end}}</td>
<td>{{if $d.MAC}}<code>{{$d.MAC}}</code>{{end}}</td>
<td>
<input type="hidden" name="dev.{{$i}}" value="{{$d.Name}}">
<input type="hidden" name="mac.{{$i}}" value="{{$d.MAC}}">
{{if eq $d.Kind "VLAN"}}
<input type="hidden" name="from.{{$i}}" value="{{$d.Custom}}">
<input name="name.{{$i}}" value="{{$d.Custom}}" size="12" placeholder="{{$d.Name}}" spellcheck="false" {{if not $d.Role}}disabled{{end}}>
{{else}}
<input type="hidden" name="from.{{$i}}" value="{{$d.Name}}">
<input name="name.{{$i}}" value="{{$d.Name}}" size="12" spellcheck="false" {{if not $d.MAC}}disabled{{end}}>
{{if and $d.Kernel (ne $d.Kernel $d.Name)}}<span class="hint">after the next reboot</span>{{end}}
{{end}}
{{with index $e (printf "name[%d]" $i)}}<span class="field-error">{{.}}</span>{{end}}
</td>
<td>{{$d.Kind}}</td><td>{{if $d.Role}}{{$d.Role}}{{else}}<span class="hint">unused</span>{{end}}</td>
<td>{{if $d.Removable}}<button formaction="/vlan" name="remove" value="{{$d.Name}}" class="link">remove</button>{{end}}</td>
</tr>{{end}}
</table>
<div class="sub">
<p class="hint">Add a VLAN (a tagged network on one of the interfaces); it is then offered below.</p>
<label>On <select name="parent">{{range .Devices}}{{if eq .Kind "physical"}}<option value="{{.Name}}">{{.Name}}</option>{{end}}{{end}}</select></label>
<label>VLAN ID <input name="id" size="5" inputmode="numeric"></label>
<button name="add" value="1">Add VLAN</button>
</div>
</fieldset>
</form>
<p class="hint">A name is what Linux calls the interface: <code>wan</code> or <code>lan</code> instead of <code>enp2s0</code>,
<code>dmz</code> instead of <code>lan.1002</code>. Cards are found by their MAC address. Linux cannot rename an
interface that is in use: after you save and apply, the router starts using the new names at its next reboot.
Clear a name to go back to the default.</p>
<button>Save names</button>
</form>
<form method="post" action="/vlan" class="sub">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<p class="hint">Add a VLAN (a tagged network on one of the interfaces); it is then offered below.</p>
<label>On <select name="parent">{{range .Devices}}{{if eq .Kind "physical"}}<option value="{{.Name}}">{{.Name}}</option>{{end}}{{end}}</select></label>
<label>VLAN ID <input name="id" size="5" inputmode="numeric"></label>
<button name="add" value="1">Add VLAN</button>
</form>
</fieldset>
<form method="post" action="/network">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
+1 -1
View File
@@ -13,7 +13,7 @@
</section>
{{else if $s.Unapplied}}
<section class="pending">
<p>The settings have changes that are not part of the confirmed system.</p>
<p>The settings have changes that are not part of the confirmed system. <a href="/changes">Review the changes</a>.</p>
<div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>
+63
View File
@@ -0,0 +1,63 @@
{{define "content"}}
{{with .Data}}
<section>
<p>An assistant or a script can read this router's state and logs and edit its settings through an
<a href="https://modelcontextprotocol.io">MCP</a> endpoint at <code>{{.URL}}</code> and, with the same features, a
REST API described by <a href="/api/openapi.json">OpenAPI</a> (<code>/api/v1/…</code>). It needs a token, sent as
<code>Authorization: Bearer …</code>. A <strong>read</strong> token reads state and settings; a <strong>change</strong>
token can also edit the settings. Edits are saved but never applied: you review them under
<a href="/changes">Changes</a> and apply them yourself. Secrets are never sent, and every call is logged below.</p>
</section>
{{if .Secret}}
<section class="pending">
<p>Token for <strong>{{.Name}}</strong>:</p>
<p><code>{{.Secret}}</code></p>
<p class="hint">For Claude Code: <code>claude mcp add --transport http router {{.URL}} --header "Authorization: Bearer {{.Secret}}"</code>.
The web UI's certificate is self-signed unless you set up a <a href="/certificate">certificate</a>: the client must trust it.</p>
</section>
{{end}}
<fieldset>
<legend>Tokens</legend>
{{if .Tokens}}
<div class="scroll">
<table>
<tr><th>Name</th><th>Access</th><th>Created</th><th>Last used</th><th></th></tr>
{{range .Tokens}}<tr><td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created.Format "2006-01-02"}}</td>
<td>{{if .LastUsed.IsZero}}<span class="hint">never</span>{{else}}{{.LastUsed.Format "2006-01-02 15:04"}}{{end}}</td>
<td><form method="post" action="/tokens/revoke" data-confirm="Revoke this token?"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><button class="link">revoke</button></form></td></tr>{{end}}
</table>
</div>
{{else}}<p class="hint">No tokens yet.</p>{{end}}
</fieldset>
<form method="post" action="/tokens">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>New token</legend>
<label>Name <input name="name" size="20" placeholder="claude on my laptop" required></label>
<label>Access
<select name="scope">
<option value="read">read: state and settings</option>
<option value="change">change: also edit the settings</option>
</select>
</label>
<button>Create token</button>
</fieldset>
</form>
<fieldset>
<legend>Recent calls</legend>
{{if .Audit}}
<div class="scroll">
<table>
<tr><th>Time</th><th>Token</th><th>Tool</th><th>Arguments</th><th>Result</th></tr>
{{range .Audit}}<tr><td>{{.Time.Format "2006-01-02 15:04:05"}}</td><td>{{.Token}}</td><td><code>{{.Tool}}</code></td>
<td><code>{{.Args}}</code></td><td>{{if .Error}}<span class="bad">{{.Error}}</span>{{else}}ok{{end}}</td></tr>{{end}}
</table>
</div>
{{else}}<p class="hint">Nothing yet.</p>{{end}}
</fieldset>
{{end}}
{{end}}
+226
View File
@@ -0,0 +1,226 @@
package main
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
// API tokens for programmatic access (the MCP endpoint). A token is shown once
// when it is made; only its SHA-256 is stored. Scopes:
//
// read state and settings
// change also edit the settings (they stay unapplied until a person applies
// them in the web UI)
//
// An "apply" scope is reserved for later.
const (
scopeRead = "read"
scopeChange = "change"
tokenPrefix = "rtr_"
maxTokens = 32
)
type Token struct {
ID string `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
Hash string `json:"hash"` // hex SHA-256 of the secret
Created time.Time `json:"created"`
LastUsed time.Time `json:"lastUsed,omitzero"`
}
func (s System) tokensPath() string { return filepath.Join(s.StateDir, "webui", "tokens.json") }
func (s System) auditPath() string { return filepath.Join(s.StateDir, "webui", "audit.log") }
// tokenStore keeps the tokens in a file; every call reads it, so edits and
// restarts need no care.
type tokenStore struct {
mu sync.Mutex
path string
lastSave map[string]time.Time // when LastUsed of a token was last written
}
func newTokenStore(path string) *tokenStore {
return &tokenStore{path: path, lastSave: map[string]time.Time{}}
}
func (t *tokenStore) load() []Token {
data, err := os.ReadFile(t.path)
if err != nil {
return nil
}
var toks []Token
if json.Unmarshal(data, &toks) != nil {
return nil
}
return toks
}
func (t *tokenStore) save(toks []Token) error {
data, err := json.MarshalIndent(toks, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(t.path), 0o700); err != nil {
return err
}
return writeFileAtomic(t.path, append(data, '\n'), 0o600)
}
func hashSecret(secret string) string {
h := sha256.Sum256([]byte(secret))
return hex.EncodeToString(h[:])
}
func (t *tokenStore) List() []Token {
t.mu.Lock()
defer t.mu.Unlock()
return t.load()
}
// Create makes a token and returns its secret, which is not stored.
func (t *tokenStore) Create(name, scope string) (Token, string, error) {
name = strings.TrimSpace(name)
switch {
case name == "" || len(name) > 40:
return Token{}, "", errors.New("give the token a name of up to 40 characters")
case scope != scopeRead && scope != scopeChange:
return Token{}, "", fmt.Errorf("unknown scope %q", scope)
}
t.mu.Lock()
defer t.mu.Unlock()
toks := t.load()
if len(toks) >= maxTokens {
return Token{}, "", errors.New("too many tokens; revoke one first")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return Token{}, "", err
}
id := make([]byte, 4)
if _, err := rand.Read(id); err != nil {
return Token{}, "", err
}
secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
tok := Token{ID: hex.EncodeToString(id), Name: name, Scope: scope, Hash: hashSecret(secret), Created: time.Now().UTC().Truncate(time.Second)}
return tok, secret, t.save(append(toks, tok))
}
func (t *tokenStore) Revoke(id string) error {
t.mu.Lock()
defer t.mu.Unlock()
toks := t.load()
n := len(toks)
toks = slices.DeleteFunc(toks, func(k Token) bool { return k.ID == id })
if len(toks) == n {
return errors.New("no such token")
}
return t.save(toks)
}
// Lookup finds the token for a secret and notes that it was used.
func (t *tokenStore) Lookup(secret string) (Token, bool) {
if !strings.HasPrefix(secret, tokenPrefix) {
return Token{}, false
}
want := hashSecret(secret)
t.mu.Lock()
defer t.mu.Unlock()
toks := t.load()
found := -1
for i, k := range toks {
if subtle.ConstantTimeCompare([]byte(k.Hash), []byte(want)) == 1 {
found = i
}
}
if found < 0 {
return Token{}, false
}
now := time.Now().UTC().Truncate(time.Second)
tok := toks[found]
tok.LastUsed = now
// Writing on every call would wear the disk; once in a few minutes is enough.
if now.Sub(t.lastSave[tok.ID]) > 5*time.Minute {
toks[found] = tok
if t.save(toks) == nil {
t.lastSave[tok.ID] = now
}
}
return tok, true
}
func (k Token) allows(scope string) bool {
return scope == scopeRead || k.Scope == scopeChange
}
// Audit log: one JSON line per call of a tool, kept to the last 1 MB (and the
// file before it).
type AuditEntry struct {
Time time.Time `json:"time"`
Token string `json:"token"`
Tool string `json:"tool"`
Args string `json:"args,omitempty"`
Error string `json:"error,omitempty"`
}
var auditMu sync.Mutex
func (s System) audit(e AuditEntry) {
auditMu.Lock()
defer auditMu.Unlock()
if len(e.Args) > 300 {
e.Args = e.Args[:300] + "…"
}
path := s.auditPath()
if fi, err := os.Stat(path); err == nil && fi.Size() > 1<<20 {
_ = os.Rename(path, path+".1")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
line, _ := json.Marshal(e)
fmt.Fprintf(f, "%s\n", line)
}
// RecentAudit returns the last n entries, newest first.
func (s System) RecentAudit(n int) []AuditEntry {
auditMu.Lock()
defer auditMu.Unlock()
f, err := os.Open(s.auditPath())
if err != nil {
return nil
}
defer f.Close()
var all []AuditEntry
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
var e AuditEntry
if json.Unmarshal(sc.Bytes(), &e) == nil {
all = append(all, e)
}
}
slices.Reverse(all)
if len(all) > n {
all = all[:n]
}
return all
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"net/http"
"net/url"
"strings"
)
type tokensData struct {
Tokens []Token
Audit []AuditEntry
URL string // the endpoint, for the example
Secret string // a token made just now: shown once
Name string
HasDoc bool
}
func (a *app) tokensData(r *http.Request) tokensData {
return tokensData{Tokens: a.tokens.List(), Audit: a.sys.RecentAudit(30), URL: "https://" + r.Host + "/mcp", HasDoc: a.optionsFile != ""}
}
func (a *app) tokensPage(w http.ResponseWriter, r *http.Request, sess session) {
a.render(w, "tokens", page{Title: "API access", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"), Data: a.tokensData(r)})
}
func (a *app) tokenCreate(w http.ResponseWriter, r *http.Request, sess session) {
tok, secret, err := a.tokens.Create(r.PostFormValue("name"), r.PostFormValue("scope"))
p := page{Title: "API access", CSRF: sess.csrf, LoggedIn: true}
d := a.tokensData(r)
if err != nil {
p.Error = err.Error()
w.WriteHeader(http.StatusUnprocessableEntity)
} else {
d.Secret, d.Name = secret, tok.Name
p.Flash = "Token " + tok.Name + " created. Copy it now: it is not shown again."
}
p.Data = d
a.render(w, "tokens", p)
}
func (a *app) tokenRevoke(w http.ResponseWriter, r *http.Request, _ session) {
msg := "Token revoked."
if err := a.tokens.Revoke(strings.TrimSpace(r.PostFormValue("id"))); err != nil {
msg = "Could not revoke: " + err.Error()
}
http.Redirect(w, r, "/tokens?msg="+url.QueryEscape(msg), http.StatusSeeOther)
}
// changesPage shows what differs between the confirmed settings and the saved
// ones, for a person to review before applying.
func (a *app) changesPage(w http.ResponseWriter, r *http.Request, sess session) {
a.render(w, "changes", page{Title: "Changes", CSRF: sess.csrf, LoggedIn: true, Data: map[string]any{
"Diff": a.sys.pendingChanges(), "Unapplied": a.sys.Status().Unapplied,
}})
}