Files
nixos-router/pkgs/router-webui/apiref.go
T
nilsandClaude Sonnet 5.5 876080d3f5 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
2026-11-01 22:01:30 +01:00

132 lines
6.1 KiB
Go

package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"strings"
)
// The API reference is Redoc (static/redoc.standalone.js) showing
// /api/openapi.json, so what is written below is the text of that document.
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.
# Authentication
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.
# 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
}
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"
}
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()}}
}
}
}
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) {
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)
}