Show the API reference with Redoc

Replace the hand-built reference page with Redoc rendering the generated
OpenAPI document. The bundle is fetched by package.nix (pinned hash) and
served from the UI's own address; only that page gets a style nonce and
worker-src blob: in its CSP.

The spec now explains authentication, saved-not-applied, ETags (If-Match,
409) and errors, documents the ETag header, gives each operation a curl
sample, and no longer names MCP tools. Area GETs no longer emit
"parameters": null, which Redoc could not render.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-01 22:01:30 +01:00
co-authored by Claude Sonnet 5.5
parent 64e08fb485
commit 876080d3f5
14 changed files with 245 additions and 201 deletions
+1
View File
@@ -5,3 +5,4 @@ result-*
/installer /installer
/dist /dist
pkgs/router-webui/router-webui pkgs/router-webui/router-webui
pkgs/router-webui/static/redoc.standalone.js
+1 -1
View File
@@ -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. - `/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`. - **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/<name>`, `.../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`. - **Settings areas** (`/api/v1/settings/router/firewall`, `.../router/lans/<name>`, `.../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. - Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500.
## Interface names (confirmed) ## Interface names (confirmed)
+1
View File
@@ -341,6 +341,7 @@
go_1_27 go_1_27
iproute2 iproute2
jq jq
curl
coreutils coreutils
]) ./scripts/webui-dev.sh; ]) ./scripts/webui-dev.sh;
+40 -20
View File
@@ -19,20 +19,33 @@ import (
type apiRoute struct { type apiRoute struct {
method, path, tool string method, path, tool string
summary string summary string
description string // instead of the tool's, where that names MCP tools
} }
var apiRoutes = []apiRoute{ var apiRoutes = []apiRoute{
{"GET", "/api/v1/status", "get_status", "State now: uplink, pings, system, whether settings are unapplied"}, {"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"}, {"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)"}, "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."},
{"GET", "/api/v1/settings/check", "check_settings", "Evaluate the saved settings without applying them"}, {"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)",
{"GET", "/api/v1/changes", "get_pending_changes", "The difference between the confirmed and the saved settings"}, "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."},
{"DELETE", "/api/v1/changes", "discard_changes", "Throw the unapplied changes away"}, {"GET", "/api/v1/settings/check", "check_settings", "Evaluate the saved settings without applying them", ""},
{"GET", "/api/v1/interfaces", "list_interfaces", "Network cards and VLAN devices"}, {"GET", "/api/v1/changes", "get_pending_changes", "The difference between the confirmed and the saved settings", ""},
{"GET", "/api/v1/dhcp-leases", "list_dhcp_leases", "DHCP clients holding a lease"}, {"DELETE", "/api/v1/changes", "discard_changes", "Throw the unapplied changes away", ""},
{"GET", "/api/v1/update", "get_update_status", "The installed release and the last update run"}, {"GET", "/api/v1/interfaces", "list_interfaces", "Network cards and VLAN devices", ""},
{"GET", "/api/v1/apply-log", "get_apply_log", "The log and result of the last apply"}, {"GET", "/api/v1/dhcp-leases", "list_dhcp_leases", "DHCP clients holding a lease", ""},
{"GET", "/api/v1/logs", "get_logs", "Lines of the system journal"}, {"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 { 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") w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
enc.SetIndent("", " ") 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 { 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"}}}} 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{ op := map[string]any{
"operationId": rt.tool, "operationId": rt.tool,
"summary": rt.summary, "summary": rt.summary,
"description": tool.description, "description": routeText(rt, tool),
"tags": []string{tool.scope}, "tags": []string{tool.scope},
"security": []any{map[string]any{"bearer": []string{}}}, "security": []any{map[string]any{"bearer": []string{}}},
"x-scope": tool.scope, "x-scope": tool.scope,
"responses": map[string]any{ "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{}}, "application/json": map[string]any{"schema": map[string]any{}},
"text/plain": map[string]any{"schema": map[string]any{"type": "string"}}, "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 { if params != nil {
op["parameters"] = params 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) entry, _ := paths[rt.path].(map[string]any)
if entry == nil { if entry == nil {
entry = map[string]any{} entry = map[string]any{}
@@ -241,14 +257,18 @@ func openAPISpec(version string, schema map[string]any) map[string]any {
paths[p] = ops paths[p] = ops
} }
} }
addCurlSamples(paths, "https://"+host)
return map[string]any{ return map[string]any{
"openapi": "3.1.0", "openapi": "3.1.0",
"info": map[string]any{ "info": map[string]any{
"title": "nixos-router", "title": "nixos-router",
"version": version, "version": version,
"description": "The router's management API, equal to its MCP server: read its state and logs, edit the saved settings. " + "description": apiIntro,
"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.", "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": "/"}}, "servers": []any{map[string]any{"url": "/"}},
"paths": paths, "paths": paths,
+32 -6
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"regexp"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -247,12 +248,37 @@ func TestAPIReference(t *testing.T) {
if rec.Code != 200 { if rec.Code != 200 {
t.Fatalf("reference: %d", rec.Code) t.Fatalf("reference: %d", rec.Code)
} }
for _, rt := range apiRoutes { csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(body, rt.path) { m := regexp.MustCompile(`data-nonce="([^"]+)"`).FindStringSubmatch(body)
t.Errorf("%s is not on the page", rt.path) 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") { if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") {
t.Errorf("examples missing") 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")
} }
} }
+111 -97
View File
@@ -1,117 +1,131 @@
package main package main
import ( import (
"crypto/rand"
"encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"sort"
"strings" "strings"
) )
// The API reference page: the REST operations as the OpenAPI document has // The API reference is Redoc (static/redoc.standalone.js) showing
// them (both come from apiRoutes and the tools' schemas), each with a curl // /api/openapi.json, so what is written below is the text of that document.
// example for this router.
type apiParam struct { 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.
Name, Type, Description string
Required bool
}
type apiDoc struct { # Authentication
Method, Path, Summary, Scope, Description string
Params []apiParam
Body string // what the request body is, if any
Curl string
}
// apiExamples are the query strings of the examples (what a person would try). Every call needs a token from the *API access* page of the web UI, sent as ` + "`Authorization: Bearer rtr_…`" + `.
var apiExamples = map[string]string{ A **read** token may use the read operations, a **change** token also the others. Tokens expire (30 days unless
"get_logs": "?unit=router-pppoe&lines=50&grep=connect", chosen otherwise) and can be revoked. This document and the page showing it need no token.
}
func apiReference(host string) []apiDoc { # Changes are saved, never applied
var docs []apiDoc
for _, rt := range apiRoutes { An edit only changes the *saved* settings. A person reviews them on the *Changes* page of the web UI and applies
tool := toolNamed(rt.tool) them there; until then the router keeps running as it was. ` + "`GET /api/v1/changes`" + ` shows what differs and
d := apiDoc{Method: rt.method, Path: rt.path, Summary: rt.summary, Scope: tool.scope, Description: tool.description} ` + "`DELETE /api/v1/changes`" + ` throws it away. Check an edit before asking a person to apply it with
url := "https://" + host + rt.path ` + "`GET /api/v1/settings/check`" + `.
switch rt.method {
case "GET": # ETags: editing without overwriting someone else
props, _ := tool.schema["properties"].(map[string]any)
required, _ := tool.schema["required"].([]string) The settings can be edited by several parties at once: people in the web UI, other tokens, other scripts. An
for name, p := range props { **ETag** is a label for one version of the settings. It changes whenever they change.
pm, _ := p.(map[string]any)
typ, _ := pm["type"].(string) 1. Read the settings (or any part of them) with ` + "`GET`" + `. The response has an ` + "`ETag`" + ` header, for example ` + "`\"3f9a1c…\"`" + `.
desc, _ := pm["description"].(string) 2. Send your edit with ` + "`If-Match: \"3f9a1c…\"`" + `: "apply this only if the settings are still that version".
d.Params = append(d.Params, apiParam{name, typ, desc, slices.Contains(required, name)}) 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 { url := base + strings.NewReplacer("{name}", "NAME").Replace(path)
if a.Required != b.Required { var b strings.Builder
if a.Required { b.WriteString("curl -sk")
return -1 if method != "get" {
} b.WriteString(" -X " + strings.ToUpper(method))
return 1 }
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) fmt.Fprintf(&b, " \\\n -H 'Content-Type: %s'", ct)
}) if method == "patch" && path == "/api/v1/settings" {
d.Curl = fmt.Sprintf("curl -sk -H \"Authorization: Bearer $TOKEN\" '%s%s'", url, apiExamples[rt.tool]) b.WriteString(` \
case "PATCH": -d '{"router":{"ddns":{"ipv6":false}}}'`)
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}} } else {
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." b.WriteString(" \\\n -d @value.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) 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) { 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{ raw := make([]byte, 16)
"Base": "https://" + r.Host, "Ops": apiReference(r.Host), "HasOptions": a.optionsFile != "", _, _ = rand.Read(raw)
"Areas": settingsAreas(a.optionsFile), "Host": r.Host, 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")
type areaOption struct{ Name, Type, Default, Description string } fmt.Fprintf(w, `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
type areaDoc struct { <title>API reference</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/static/apiref.css"></head>
Path string <body><div id="redoc" data-spec="/api/openapi.json" data-nonce="%s"></div>
Options []areaOption <script src="/static/redoc.standalone.js"></script><script src="/static/apiref.js"></script></body></html>
} `, nonce)
// 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
} }
+3 -3
View File
@@ -223,7 +223,7 @@ func areaPaths(schema map[string]any) map[string]any {
path := "/api/v1/settings/" + strings.Join(parts, "/") path := "/api/v1/settings/" + strings.Join(parts, "/")
node := unwrapNullable(s) node := unwrapNullable(s)
id := strings.NewReplacer("/", "_", "{", "", "}", "").Replace(strings.Join(parts, "_")) id := strings.NewReplacer("/", "_", "{", "", "}", "").Replace(strings.Join(parts, "_"))
var params []any params := []any{}
for _, p := range parts { for _, p := range parts {
if p == "{name}" { 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)."}) 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 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 { 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}}} 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 { ok200 := func(desc string, sch map[string]any) map[string]any {
r := errs("400", "404", "409") 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 return r
} }
sec := []any{map[string]any{"bearer": []string{}}} sec := []any{map[string]any{"bearer": []string{}}}
+23 -8
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -264,14 +265,28 @@ func TestLiveSettingsValidate(t *testing.T) {
} }
} }
func TestAreasOnReferencePage(t *testing.T) { // Viewers (Redoc) fail on a null where the OpenAPI document needs a list.
a, h, _ := areaApp(t) func TestSpecHasNoNulls(t *testing.T) {
cookie := login(t, h) a, _, _ := areaApp(t)
_ = a spec := openAPISpec("1.2.3", a.settingsSchema(), "router.example")
body := do(h, "GET", "/apireference", nil, cookie).Body.String() var walk func(path string, v any)
for _, want := range []string{"/api/v1/settings/router/lans", "/api/v1/settings/router/firewall", "&lt;name&gt;.vlan", "null or integer between 1 and 4094", "Settings areas"} { walk = func(path string, v any) {
if !strings.Contains(body, want) { switch x := v.(type) {
t.Errorf("%q is missing from the reference page", want) 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)
} }
+1 -1
View File
@@ -270,7 +270,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
func (a *app) parseTemplates() (map[string]*template.Template, error) { func (a *app) parseTemplates() (map[string]*template.Template, error) {
files := map[string][]string{"overview": {"templates/dashboard.html"}} files := map[string][]string{"overview": {"templates/dashboard.html"}}
pages := map[string]*template.Template{} 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). t, err := template.New("layout.html").Funcs(a.funcs).
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...) ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
if err != nil { if err != nil {
+17 -1
View File
@@ -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 { buildGo127Module {
pname = "router-webui"; pname = "router-webui";
version = "0.1.0"; version = "0.1.0";
@@ -12,6 +23,11 @@ buildGo127Module {
./static ./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. # Standard library only.
vendorHash = null; vendorHash = null;
meta = { meta = {
+1
View File
@@ -0,0 +1 @@
body { margin: 0; }
+11
View File
@@ -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);
})();
-64
View File
@@ -1,64 +0,0 @@
{{define "content"}}
{{with .Data}}
<section class="panel">
<h2>Using the API</h2>
<p>Everything the MCP server offers, over plain HTTP. Base address <code>{{.Base}}</code>; the machine-readable description
is <a href="/api/openapi.json">/api/openapi.json</a> (OpenAPI 3.1, no token needed).</p>
<p>Every call needs a token on the <a href="/tokens">API access</a> page, sent as <code>Authorization: Bearer rtr_…</code>.
A <strong>read</strong> token may use the read operations; a <strong>change</strong> token also the others. Changes to the
settings are saved, never applied: you review them under <a href="/changes">Changes</a> and apply them yourself.</p>
<p>Errors are JSON, <code>{"error": "…"}</code>, 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}} <code>GET /api/v1/options</code> lists every router option with its type,
default and description: what <code>settings</code> may contain.{{end}}</p>
<p class="hint">Examples use <code>-k</code> because the certificate is self-signed unless you set up a
<a href="/certificate">certificate</a>; put your token in <code>$TOKEN</code>.</p>
</section>
<div class="cols">
{{range .Ops}}
<section class="panel">
<h2><code>{{.Method}} {{.Path}}</code> <span class="muted">{{.Summary}}</span></h2>
<p class="hint">Access: {{.Scope}}</p>
<p>{{.Description}}</p>
{{if .Params}}
<div class="scroll"><table>
<tr><th>Parameter</th><th>Type</th><th></th><th>Description</th></tr>
{{range .Params}}<tr><td><code>{{.Name}}</code></td><td>{{.Type}}</td><td>{{if .Required}}required{{end}}</td><td>{{.Description}}</td></tr>{{end}}
</table></div>
{{end}}
{{with .Body}}<p><strong>Body:</strong> {{.}}</p>{{end}}
<pre class="log">{{.Curl}}</pre>
</section>
{{end}}
</div>
<section class="panel">
<h2>Settings areas</h2>
<p>The settings are a tree, and every part of it has its own path below <code>/api/v1/settings</code>:
<code>router/firewall</code>, <code>router/lans/lan</code> (one LAN, by its name), <code>router/dns/hosts</code>,
<code>system</code>, down to a single option like <code>router/wan/interface</code> (lists are replaced whole; their
elements have no paths). The OpenAPI document describes each with the type of its value.</p>
<ul>
<li><code>GET</code> reads the part (404 when it is not set and the defaults apply); the <code>ETag</code> header is the
settings' version.</li>
<li><code>PUT</code> replaces it, <code>PATCH</code> merges a JSON merge patch into it (objects only), <code>DELETE</code> removes
it. They need <code>If-Match</code> with the ETag, need a change token, and are refused (400, with the place) when a value
does not fit its option's type or a key is not an option.</li>
<li>Like every edit through the API they are saved, never applied; <code>GET /api/v1/settings/check</code> evaluates them.</li>
</ul>
<pre class="log">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</pre>
</section>
{{range .Areas}}
<section class="panel">
<h2><code>{{.Path}}</code></h2>
<div class="scroll"><table class="opts">
<tr><th>Option</th><th>Type</th><th>Default</th><th>Description</th></tr>
{{range .Options}}<tr><td><code>{{.Name}}</code></td><td>{{.Type}}</td><td>{{with .Default}}<code>{{.}}</code>{{end}}</td><td>{{.Description}}</td></tr>{{end}}
</table></div>
<p class="hint"><code>&lt;name&gt;</code> is the key of an entry in a map, <code>*</code> an element of a list.</p>
</section>
{{end}}
{{end}}
{{end}}
+3
View File
@@ -21,6 +21,9 @@ address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostn
LEASES LEASES
echo '{"version": "0.0.0-dev", "serial": 1, "schema": 2}' > "$dir/router-release" echo '{"version": "0.0.0-dev", "serial": 1, "schema": 2}' > "$dir/router-release"
cd "$root/pkgs/router-webui" 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 devpassword1 | go run . passwd "$dir/webui-password"
echo echo
echo " https://127.0.0.1:8443 password: devpassword1 (the certificate is self-signed)" echo " https://127.0.0.1:8443 password: devpassword1 (the certificate is self-signed)"