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
132 lines
6.1 KiB
Go
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)
|
|
}
|