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
+40 -20
View File
@@ -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,
+32 -6
View File
@@ -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
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)
}
+3 -3
View File
@@ -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{}}}
+23 -8
View File
@@ -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", "&lt;name&gt;.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)
}
+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) {
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 {
+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 {
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 = {
+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}}