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
+111 -97
View File
@@ -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)
}