Add the appliance web UI look, interface names, MCP and REST API, local zone
Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes), theme preference on the Advanced page, Changes page with the pending diff. Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and pppoe.vlanName; a rename is staged for the next reboot by router-apply. Remote access: an MCP server (POST /mcp) and an OpenAPI REST API (/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit log; tools to read status, settings, leases, logs (get_logs), and to edit and check the saved settings (never applied). Tokens on the API access page. Firewall: a reserved `local` zone (the router itself) as the destination of rules, so the web UI or SSH can be allowed from the WAN. PLAN.md lists what is still left. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -0,0 +1,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"}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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], " ")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}})
|
||||
}
|
||||
Reference in New Issue
Block a user