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, ` API reference
`, nonce) }