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:
@@ -5,3 +5,4 @@ result-*
|
||||
/installer
|
||||
/dist
|
||||
pkgs/router-webui/router-webui
|
||||
pkgs/router-webui/static/redoc.standalone.js
|
||||
|
||||
@@ -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/<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.
|
||||
|
||||
## Interface names (confirmed)
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
go_1_27
|
||||
iproute2
|
||||
jq
|
||||
curl
|
||||
coreutils
|
||||
]) ./scripts/webui-dev.sh;
|
||||
|
||||
|
||||
+40
-20
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+111
-97
@@ -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, `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>API reference</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/static/apiref.css"></head>
|
||||
<body><div id="redoc" data-spec="/api/openapi.json" data-nonce="%s"></div>
|
||||
<script src="/static/redoc.standalone.js"></script><script src="/static/apiref.js"></script></body></html>
|
||||
`, nonce)
|
||||
}
|
||||
|
||||
@@ -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{}}}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
body { margin: 0; }
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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><name></code> is the key of an entry in a map, <code>*</code> an element of a list.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user