diff --git a/.gitignore b/.gitignore index a71f435..5e04f44 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ result-* /installer /dist pkgs/router-webui/router-webui +pkgs/router-webui/static/redoc.standalone.js diff --git a/SPECS.md b/SPECS.md index ab509ff..7c17367 100644 --- a/SPECS.md +++ b/SPECS.md @@ -125,7 +125,7 @@ Learned along the way: - `/api/v1/…` with an OpenAPI 3.1 description at `GET /api/openapi.json` (no token: it describes the interface only). Same tokens, scopes, origin check, limits and audit log as MCP. - **One implementation**: each operation runs an MCP tool (`apiRoutes` in `api.go` maps method and path to a tool; query parameters or `If-Match` and the body become its arguments), and `TestOpenAPI` checks that every tool has an operation. Status, settings (`ETag` header), `PATCH /settings` (merge patch, `If-Match`; 409 when stale), `settings/check`, `changes` (GET the diff as text, DELETE to discard), `interfaces`, `dhcp-leases`, `update`, `apply-log`, `logs`, `options`. - **Settings areas** (`/api/v1/settings/router/firewall`, `.../router/lans/`, `.../system`, down to a single option): `GET` reads, `PUT` replaces, `PATCH` merges (objects), `DELETE` removes, with the settings' `ETag`/`If-Match` and the same limits as the whole-document edit (`editSettings`). Values are checked against a JSON Schema made from the option documentation (`schema.go` reads the types nixosOptionsDoc describes in words, e.g. "null or integer between 1 and 4094"; a type it does not know becomes "anything", so it never refuses a valid setting): a wrong type, a value out of range or a key that is not an option is refused with its place. The OpenAPI document has these paths (three levels deep) with their schemas; lists are replaced whole (elements have no paths). Tested with the real option file (`ROUTER_OPTIONS_JSON`, `LIVE_SETTINGS_JSON` for a router's own settings) and in `apply`. -- **API reference** page in the web UI (`/apireference`, linked from API access): each operation with its access, parameters and a `curl` example for this router, from the same route list as the OpenAPI document. No "try it" form. +- **API reference** page in the web UI (`/apireference`, linked from API access): Redoc (pinned `redoc@2.5.2` bundle, fetched by `pkgs/router-webui/package.nix`, served from the UI's own address) rendering the generated `/api/openapi.json`. Only that page gets a nonce for Redoc's styles and `worker-src blob:` in its CSP. The spec's own text explains authentication, saved-not-applied, ETags/`If-Match`/409 and errors; every operation has a curl sample. Established tooling instead of a hand-built page (swaggo rejected: spec is generated at runtime from the MCP tools and the option docs). No "try it" form. - Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500. ## Interface names (confirmed) diff --git a/flake.nix b/flake.nix index 57e7a77..4227150 100644 --- a/flake.nix +++ b/flake.nix @@ -341,6 +341,7 @@ go_1_27 iproute2 jq + curl coreutils ]) ./scripts/webui-dev.sh; diff --git a/pkgs/router-webui/api.go b/pkgs/router-webui/api.go index 9c558f7..abaa53e 100644 --- a/pkgs/router-webui/api.go +++ b/pkgs/router-webui/api.go @@ -19,20 +19,33 @@ import ( type apiRoute struct { method, path, tool string summary string + description string // instead of the tool's, where that names MCP tools } 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"}, + {"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", + "The saved settings (`system` and `router`) and whether they differ from the confirmed (running) system. The response carries the settings' ETag, which an edit must send back in `If-Match`. Secrets are not in the settings, only paths of files that hold them."}, + {"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)", + "Edit the saved settings with a JSON merge patch (RFC 7386): objects merge, a value replaces, `null` removes a key, arrays are replaced whole. Send the ETag from the last read in `If-Match`; if the settings changed since, nothing is written and the answer is 409. 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. Call `GET /api/v1/settings/check` afterwards."}, + {"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", ""}, +} + +// etagHeader documents the ETag header of the answers about the settings. +var etagHeader = map[string]any{"ETag": map[string]any{"description": "The settings' ETag (in quotes): send it in `If-Match` to edit them.", "schema": map[string]any{"type": "string"}}} + +func routeText(rt apiRoute, tool *mcpTool) string { + if rt.description != "" { + return rt.description + } + return tool.description } func toolNamed(name string) *mcpTool { @@ -173,10 +186,10 @@ 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, a.settingsSchema())) + _ = enc.Encode(openAPISpec(a.sys.installedRelease().Version, a.settingsSchema(), r.Host)) } -func openAPISpec(version string, schema map[string]any) map[string]any { +func openAPISpec(version string, schema map[string]any, host 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"}}}} } @@ -186,12 +199,12 @@ func openAPISpec(version string, schema map[string]any) map[string]any { op := map[string]any{ "operationId": rt.tool, "summary": rt.summary, - "description": tool.description, + "description": routeText(rt, tool), "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{ + "200": map[string]any{"description": "The result.", "content": map[string]any{ "application/json": map[string]any{"schema": map[string]any{}}, "text/plain": map[string]any{"schema": map[string]any{"type": "string"}}, }}, @@ -224,6 +237,9 @@ func openAPISpec(version string, schema map[string]any) map[string]any { if params != nil { op["parameters"] = params } + if rt.tool == "get_settings" || rt.tool == "update_settings" { + op["responses"].(map[string]any)["200"].(map[string]any)["headers"] = etagHeader + } entry, _ := paths[rt.path].(map[string]any) if entry == nil { entry = map[string]any{} @@ -241,14 +257,18 @@ func openAPISpec(version string, schema map[string]any) map[string]any { paths[p] = ops } } + addCurlSamples(paths, "https://"+host) 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). " + - "The settings are also a tree of areas: /api/v1/settings/router/firewall, /router/lans/{name}, /system, ... (any depth) can be read, replaced (PUT), merged into (PATCH) or removed (DELETE); the documented ones go three levels deep.", + "title": "nixos-router", + "version": version, + "description": apiIntro, + }, + "tags": []any{ + map[string]any{"name": scopeRead, "x-displayName": "Read", "description": "Allowed for every token."}, + map[string]any{"name": scopeChange, "x-displayName": "Change", "description": "Needs a token with the access *change*."}, + map[string]any{"name": "settings", "x-displayName": "Settings areas", "description": settingsAreasText}, }, "servers": []any{map[string]any{"url": "/"}}, "paths": paths, diff --git a/pkgs/router-webui/api_test.go b/pkgs/router-webui/api_test.go index 3c1f0f0..02217e0 100644 --- a/pkgs/router-webui/api_test.go +++ b/pkgs/router-webui/api_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "os" + "regexp" "slices" "strings" "testing" @@ -247,12 +248,37 @@ func TestAPIReference(t *testing.T) { if rec.Code != 200 { t.Fatalf("reference: %d", rec.Code) } - for _, rt := range apiRoutes { - if !strings.Contains(body, rt.path) { - t.Errorf("%s is not on the page", rt.path) - } + csp := rec.Header().Get("Content-Security-Policy") + m := regexp.MustCompile(`data-nonce="([^"]+)"`).FindStringSubmatch(body) + if m == nil || !strings.Contains(csp, "'nonce-"+m[1]+"'") { + t.Errorf("the page's nonce is not in its CSP: %q", csp) } - if !strings.Contains(body, "curl -sk") || !strings.Contains(body, "If-Match") || !strings.Contains(body, "unit=router-pppoe") { - t.Errorf("examples missing") + if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") { + t.Errorf("CSP too loose: %q", csp) + } + if !strings.Contains(body, `data-spec="/api/openapi.json"`) || !strings.Contains(body, "/static/redoc.standalone.js") { + t.Errorf("the page does not load Redoc with the spec:\n%s", body) + } +} + +func TestSpecExplainsETags(t *testing.T) { + spec := openAPISpec("1.2.3", nil, "router.example") + info := spec["info"].(map[string]any)["description"].(string) + for _, want := range []string{"ETag", "If-Match", "409"} { + if !strings.Contains(info, want) { + t.Errorf("%q is not explained in the spec's description", want) + } + } + patch := spec["paths"].(map[string]any)["/api/v1/settings"].(map[string]any)["patch"].(map[string]any) + if strings.Contains(patch["description"].(string), "update_settings") || strings.Contains(patch["description"].(string), "check_settings") { + t.Errorf("REST description names an MCP tool: %v", patch["description"]) + } + src := patch["x-codeSamples"].([]any)[0].(map[string]any)["source"].(string) + if !strings.Contains(src, "https://router.example/api/v1/settings") || !strings.Contains(src, "If-Match") { + t.Errorf("curl sample: %s", src) + } + get := spec["paths"].(map[string]any)["/api/v1/settings"].(map[string]any)["get"].(map[string]any) + if _, ok := get["responses"].(map[string]any)["200"].(map[string]any)["headers"].(map[string]any)["ETag"]; !ok { + t.Errorf("GET settings does not document its ETag header") } } diff --git a/pkgs/router-webui/apiref.go b/pkgs/router-webui/apiref.go index a95144f..5b84c7c 100644 --- a/pkgs/router-webui/apiref.go +++ b/pkgs/router-webui/apiref.go @@ -1,117 +1,131 @@ package main import ( + "crypto/rand" + "encoding/base64" "fmt" "net/http" - "slices" - "sort" "strings" ) -// The API reference page: the REST operations as the OpenAPI document has -// them (both come from apiRoutes and the tools' schemas), each with a curl -// example for this router. +// The API reference is Redoc (static/redoc.standalone.js) showing +// /api/openapi.json, so what is written below is the text of that document. -type apiParam struct { - Name, Type, Description string - Required bool -} +const apiIntro = `The router's management API. It does what the MCP server does: read the router's state and logs, and edit its saved settings. -type apiDoc struct { - Method, Path, Summary, Scope, Description string - Params []apiParam - Body string // what the request body is, if any - Curl string -} +# Authentication -// apiExamples are the query strings of the examples (what a person would try). -var apiExamples = map[string]string{ - "get_logs": "?unit=router-pppoe&lines=50&grep=connect", -} +Every call needs a token from the *API access* page of the web UI, sent as ` + "`Authorization: Bearer rtr_…`" + `. +A **read** token may use the read operations, a **change** token also the others. Tokens expire (30 days unless +chosen otherwise) and can be revoked. This document and the page showing it need no token. -func apiReference(host string) []apiDoc { - var docs []apiDoc - for _, rt := range apiRoutes { - tool := toolNamed(rt.tool) - d := apiDoc{Method: rt.method, Path: rt.path, Summary: rt.summary, Scope: tool.scope, Description: tool.description} - url := "https://" + host + rt.path - switch rt.method { - case "GET": - props, _ := tool.schema["properties"].(map[string]any) - required, _ := tool.schema["required"].([]string) - for name, p := range props { - pm, _ := p.(map[string]any) - typ, _ := pm["type"].(string) - desc, _ := pm["description"].(string) - d.Params = append(d.Params, apiParam{name, typ, desc, slices.Contains(required, name)}) +# Changes are saved, never applied + +An edit only changes the *saved* settings. A person reviews them on the *Changes* page of the web UI and applies +them there; until then the router keeps running as it was. ` + "`GET /api/v1/changes`" + ` shows what differs and +` + "`DELETE /api/v1/changes`" + ` throws it away. Check an edit before asking a person to apply it with +` + "`GET /api/v1/settings/check`" + `. + +# ETags: editing without overwriting someone else + +The settings can be edited by several parties at once: people in the web UI, other tokens, other scripts. An +**ETag** is a label for one version of the settings. It changes whenever they change. + +1. Read the settings (or any part of them) with ` + "`GET`" + `. The response has an ` + "`ETag`" + ` header, for example ` + "`\"3f9a1c…\"`" + `. +2. Send your edit with ` + "`If-Match: \"3f9a1c…\"`" + `: "apply this only if the settings are still that version". +3. If somebody saved a change in between, the ETag no longer matches. Nothing is written and the answer is + **409 Conflict**. Read again, redo the edit on the new settings, and retry. +4. If it matches, the edit is saved and the response carries the **new ETag** for your next edit. + +Every operation that changes the settings (` + "`PATCH`, `PUT`, `DELETE`" + ` below ` + "`/api/v1/settings`" + `) needs +` + "`If-Match`" + `; without it the answer is 400. All parts of the settings share the one ETag. + +# Errors + +Errors are JSON, ` + "`{\"error\": \"…\"}`" + `: **400** refused (the message says why), **401** no, unknown or expired token, +**403** the token's access is too low, **404** not set or no such option, **409** stale ETag, **500** the router failed. +Results are JSON, except the pending changes and the journal, which are plain text.` + +const settingsAreasText = `The settings are a tree, and every part of it has its own path below ` + "`/api/v1/settings`" + `: +` + "`router/firewall`, `router/lans/{name}` (one LAN, by its name), `router/dns/hosts`, `system`" + `, down to a single option +like ` + "`router/wan/interface`" + `. The documented paths go three levels deep; deeper ones work the same way. Lists are replaced +whole, their elements have no paths of their own. + +- ` + "`GET`" + ` reads a part (404: not set, so its defaults apply), +- ` + "`PUT`" + ` replaces it, ` + "`PATCH`" + ` merges into an object, ` + "`DELETE`" + ` removes it, +- all three edits need ` + "`If-Match`" + ` (see *ETags* above) and are saved, not applied. + +Every value is checked against the option's type before it is saved; a wrong type, a number out of range or a key +that is no option is refused with its place, for example ` + "`/router/dns/domain: expected a string`" + `. +` + "`GET /api/v1/options`" + ` lists every option with its type, default and description.` + +// addCurlSamples gives every operation a curl example for this router. +func addCurlSamples(paths map[string]any, base string) { + for path, v := range paths { + ops, _ := v.(map[string]any) + for method, o := range ops { + op, _ := o.(map[string]any) + if op == nil { + continue } - slices.SortFunc(d.Params, func(a, b apiParam) int { - if a.Required != b.Required { - if a.Required { - return -1 - } - return 1 + url := base + strings.NewReplacer("{name}", "NAME").Replace(path) + var b strings.Builder + b.WriteString("curl -sk") + if method != "get" { + b.WriteString(" -X " + strings.ToUpper(method)) + } + b.WriteString(` -H "Authorization: Bearer $TOKEN"`) + if hasHeaderParam(op, "If-Match") { + b.WriteString(` \ + -H 'If-Match: "ETAG"'`) + } + if op["requestBody"] != nil { + ct := "application/json" + if method == "patch" && path == "/api/v1/settings" { + ct = "application/merge-patch+json" } - return strings.Compare(a.Name, b.Name) - }) - d.Curl = fmt.Sprintf("curl -sk -H \"Authorization: Bearer $TOKEN\" '%s%s'", url, apiExamples[rt.tool]) - case "PATCH": - d.Params = []apiParam{{"If-Match (header)", "string", "The ETag from GET /api/v1/settings; the change is refused (409) if the settings changed since.", true}} - d.Body = "A JSON merge patch (RFC 7386) on the settings: objects merge, a value replaces, null removes a key, arrays are replaced whole. Content-Type application/merge-patch+json or application/json." - d.Curl = fmt.Sprintf("curl -sk -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'If-Match: \"ETAG\"' \\\n -H 'Content-Type: application/merge-patch+json' \\\n -d '{\"router\":{\"ddns\":{\"ipv6\":false}}}' %s", url) - default: - d.Curl = fmt.Sprintf("curl -sk -X %s -H \"Authorization: Bearer $TOKEN\" %s", rt.method, url) + fmt.Fprintf(&b, " \\\n -H 'Content-Type: %s'", ct) + if method == "patch" && path == "/api/v1/settings" { + b.WriteString(` \ + -d '{"router":{"ddns":{"ipv6":false}}}'`) + } else { + b.WriteString(" \\\n -d @value.json") + } + } + q := "" + if op["operationId"] == "get_logs" { + q = "?unit=router-pppoe&lines=50&grep=connect" + } + fmt.Fprintf(&b, " \\\n '%s%s'", url, q) + op["x-codeSamples"] = []any{map[string]any{"lang": "Shell", "label": "curl", "source": b.String()}} } - docs = append(docs, d) } - return docs } +func hasHeaderParam(op map[string]any, name string) bool { + ps, _ := op["parameters"].([]any) + for _, p := range ps { + if m, _ := p.(map[string]any); m != nil && m["in"] == "header" && m["name"] == name { + return true + } + } + return false +} + +// apiReference serves the page that shows the OpenAPI document with Redoc. +// Redoc writes its styles from script, so this page (and only it) allows +// styles carrying a nonce, and the worker it searches in. func (a *app) apiReference(w http.ResponseWriter, r *http.Request, sess session) { - a.render(w, "apiref", page{Title: "API reference", CSRF: sess.csrf, LoggedIn: true, Data: map[string]any{ - "Base": "https://" + r.Host, "Ops": apiReference(r.Host), "HasOptions": a.optionsFile != "", - "Areas": settingsAreas(a.optionsFile), "Host": r.Host, - }}) -} - -type areaOption struct{ Name, Type, Default, Description string } - -type areaDoc struct { - Path string - Options []areaOption -} - -// settingsAreas lists the areas below router/ with their options (from the -// option documentation): what a person can read and write there. -func settingsAreas(optionsFile string) []areaDoc { - if optionsFile == "" { - return nil - } - entries, err := loadOptions(optionsFile) - if err != nil { - return nil - } - byArea := map[string][]areaOption{} - for _, e := range entries { - if len(e.Loc) < 3 || e.Loc[0] != "router" { - continue - } - desc := strings.TrimSpace(e.Description) - if i := strings.Index(desc, "\n\n"); i > 0 { - desc = desc[:i] - } - desc = strings.Join(strings.Fields(desc), " ") - if len(desc) > 300 { - desc = desc[:300] + "…" - } - name := strings.Join(e.Loc[2:], ".") - byArea[e.Loc[1]] = append(byArea[e.Loc[1]], areaOption{name, e.Type, literal(e.Default), desc}) - } - var areas []areaDoc - for k, opts := range byArea { - sort.Slice(opts, func(i, j int) bool { return opts[i].Name < opts[j].Name }) - areas = append(areas, areaDoc{Path: "/api/v1/settings/router/" + k, Options: opts}) - } - sort.Slice(areas, func(i, j int) bool { return areas[i].Path < areas[j].Path }) - return areas + raw := make([]byte, 16) + _, _ = rand.Read(raw) + nonce := base64.RawStdEncoding.EncodeToString(raw) + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'nonce-"+nonce+"'; worker-src blob:; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'") + h.Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + +API reference +
+ +`, nonce) } diff --git a/pkgs/router-webui/areas.go b/pkgs/router-webui/areas.go index ddeba80..3dadea8 100644 --- a/pkgs/router-webui/areas.go +++ b/pkgs/router-webui/areas.go @@ -223,7 +223,7 @@ func areaPaths(schema map[string]any) map[string]any { path := "/api/v1/settings/" + strings.Join(parts, "/") node := unwrapNullable(s) id := strings.NewReplacer("/", "_", "{", "", "}", "").Replace(strings.Join(parts, "_")) - var params []any + params := []any{} for _, p := range parts { if p == "{name}" { params = append(params, map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}, "description": "The name of the entry (the key in the map)."}) @@ -237,13 +237,13 @@ func areaPaths(schema map[string]any) map[string]any { } return m } - ifMatch := map[string]any{"name": "If-Match", "in": "header", "required": true, "schema": map[string]any{"type": "string"}, "description": "The ETag of the settings (GET /api/v1/settings or any area)."} + ifMatch := map[string]any{"name": "If-Match", "in": "header", "required": true, "schema": map[string]any{"type": "string"}, "description": "The ETag from the last read of the settings or of any part of them (the `ETag` header). The edit is refused with 409 if the settings changed since."} jsonBody := func(sch map[string]any) map[string]any { return map[string]any{"required": true, "content": map[string]any{"application/json": map[string]any{"schema": sch}}} } ok200 := func(desc string, sch map[string]any) map[string]any { r := errs("400", "404", "409") - r["200"] = map[string]any{"description": desc, "content": map[string]any{"application/json": map[string]any{"schema": sch}}} + r["200"] = map[string]any{"description": desc, "headers": etagHeader, "content": map[string]any{"application/json": map[string]any{"schema": sch}}} return r } sec := []any{map[string]any{"bearer": []string{}}} diff --git a/pkgs/router-webui/areas_test.go b/pkgs/router-webui/areas_test.go index 1a9a9ea..6bc18df 100644 --- a/pkgs/router-webui/areas_test.go +++ b/pkgs/router-webui/areas_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "os" "path/filepath" @@ -264,14 +265,28 @@ func TestLiveSettingsValidate(t *testing.T) { } } -func TestAreasOnReferencePage(t *testing.T) { - a, h, _ := areaApp(t) - cookie := login(t, h) - _ = a - body := do(h, "GET", "/apireference", nil, cookie).Body.String() - for _, want := range []string{"/api/v1/settings/router/lans", "/api/v1/settings/router/firewall", "<name>.vlan", "null or integer between 1 and 4094", "Settings areas"} { - if !strings.Contains(body, want) { - t.Errorf("%q is missing from the reference page", want) +// Viewers (Redoc) fail on a null where the OpenAPI document needs a list. +func TestSpecHasNoNulls(t *testing.T) { + a, _, _ := areaApp(t) + spec := openAPISpec("1.2.3", a.settingsSchema(), "router.example") + var walk func(path string, v any) + walk = func(path string, v any) { + switch x := v.(type) { + case nil: + t.Errorf("null at %s", path) + case map[string]any: + for k, c := range x { + walk(path+"/"+k, c) + } + case []any: + for i, c := range x { + walk(fmt.Sprintf("%s/%d", path, i), c) + } } } + // Round trip, as the served document is. + data, _ := json.Marshal(spec) + var doc any + _ = json.Unmarshal(data, &doc) + walk("", doc) } diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index e4e8d84..90f98d3 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -270,7 +270,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", "tokens", "changes", "apiref"} { + 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 { diff --git a/pkgs/router-webui/package.nix b/pkgs/router-webui/package.nix index e8b0702..2a8dd7c 100644 --- a/pkgs/router-webui/package.nix +++ b/pkgs/router-webui/package.nix @@ -1,4 +1,15 @@ -{ lib, buildGo127Module }: +{ + lib, + buildGo127Module, + fetchurl, +}: +let + # The API reference page is Redoc (MIT), served from the web UI's own address. + redoc = fetchurl { + url = "https://cdn.jsdelivr.net/npm/redoc@2.5.2/bundles/redoc.standalone.js"; + hash = "sha256-a+ta6iO3HQkSLuNd6PMm44rsBQ53Tzv7ofNOXDZgiYY="; + }; +in buildGo127Module { pname = "router-webui"; version = "0.1.0"; @@ -12,6 +23,11 @@ buildGo127Module { ./static ]; }; + # static/redoc.standalone.js is not in the source tree (git ignores it); it + # is embedded in the binary with the other static files. + preBuild = '' + cp ${redoc} static/redoc.standalone.js + ''; # Standard library only. vendorHash = null; meta = { diff --git a/pkgs/router-webui/static/apiref.css b/pkgs/router-webui/static/apiref.css new file mode 100644 index 0000000..bc406f0 --- /dev/null +++ b/pkgs/router-webui/static/apiref.css @@ -0,0 +1 @@ +body { margin: 0; } diff --git a/pkgs/router-webui/static/apiref.js b/pkgs/router-webui/static/apiref.js new file mode 100644 index 0000000..fb5bc13 --- /dev/null +++ b/pkgs/router-webui/static/apiref.js @@ -0,0 +1,11 @@ +// Shows the OpenAPI document with Redoc (redoc.standalone.js). +(function () { + var el = document.getElementById("redoc"); + Redoc.init(el.dataset.spec, { + nonce: el.dataset.nonce, + hideDownloadButton: false, + expandResponses: "200", + pathInMiddlePanel: true, + sortPropsAlphabetically: false, + }, el); +})(); diff --git a/pkgs/router-webui/templates/apiref.html b/pkgs/router-webui/templates/apiref.html deleted file mode 100644 index ab3682c..0000000 --- a/pkgs/router-webui/templates/apiref.html +++ /dev/null @@ -1,64 +0,0 @@ -{{define "content"}} -{{with .Data}} -
-

Using the API

-

Everything the MCP server offers, over plain HTTP. Base address {{.Base}}; the machine-readable description - is /api/openapi.json (OpenAPI 3.1, no token needed).

-

Every call needs a token on the API access page, sent as Authorization: Bearer rtr_…. - A read token may use the read operations; a change token also the others. Changes to the - settings are saved, never applied: you review them under Changes and apply them yourself.

-

Errors are JSON, {"error": "…"}, with the status 400 (refused or a bad argument), 401 (no or an expired token), - 403 (the token's access is too low), 409 (stale ETag) or 500. Results are JSON, except the pending changes and the - journal, which are plain text.{{if .HasOptions}} GET /api/v1/options lists every router option with its type, - default and description: what settings may contain.{{end}}

-

Examples use -k because the certificate is self-signed unless you set up a - certificate; put your token in $TOKEN.

-
-
-{{range .Ops}} -
-

{{.Method}} {{.Path}} {{.Summary}}

-

Access: {{.Scope}}

-

{{.Description}}

-{{if .Params}} -
- - {{range .Params}}{{end}} -
ParameterTypeDescription
{{.Name}}{{.Type}}{{if .Required}}required{{end}}{{.Description}}
-{{end}} -{{with .Body}}

Body: {{.}}

{{end}} -
{{.Curl}}
-
-{{end}} -
- -
-

Settings areas

-

The settings are a tree, and every part of it has its own path below /api/v1/settings: - router/firewall, router/lans/lan (one LAN, by its name), router/dns/hosts, - system, down to a single option like router/wan/interface (lists are replaced whole; their - elements have no paths). The OpenAPI document describes each with the type of its value.

- -
curl -sk -H "Authorization: Bearer $TOKEN" https://{{.Host}}/api/v1/settings/router/dns
-curl -sk -X PUT -H "Authorization: Bearer $TOKEN" -H 'If-Match: "ETAG"' \
-  -d '{"domain":"home.example.org","dynamicLeases":true}' https://{{.Host}}/api/v1/settings/router/dns
-
-{{range .Areas}} -
-

{{.Path}}

-
- - {{range .Options}}{{end}} -
OptionTypeDefaultDescription
{{.Name}}{{.Type}}{{with .Default}}{{.}}{{end}}{{.Description}}
-

<name> is the key of an entry in a map, * an element of a list.

-
-{{end}} -{{end}} -{{end}} diff --git a/scripts/webui-dev.sh b/scripts/webui-dev.sh index 465c699..41b76bb 100644 --- a/scripts/webui-dev.sh +++ b/scripts/webui-dev.sh @@ -21,6 +21,9 @@ address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostn LEASES echo '{"version": "0.0.0-dev", "serial": 1, "schema": 2}' > "$dir/router-release" cd "$root/pkgs/router-webui" +# The API reference page is Redoc; the package fetches it (pkgs/router-webui/package.nix, same version). +[ -e static/redoc.standalone.js ] || + curl -fsSL -o static/redoc.standalone.js https://cdn.jsdelivr.net/npm/redoc@2.5.2/bundles/redoc.standalone.js echo devpassword1 | go run . passwd "$dir/webui-password" echo echo " https://127.0.0.1:8443 password: devpassword1 (the certificate is self-signed)"