Files
nixos-router/pkgs/router-webui/api.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

281 lines
11 KiB
Go

package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"slices"
"strconv"
"strings"
)
// A REST API (described by OpenAPI at /api/openapi.json) with the same
// features, tokens, scopes, limits and audit log as the MCP server: every
// operation runs one of the MCP tools (mcpTools), so the two cannot drift.
// The routes below say how a request becomes a tool's arguments.
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",
"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 {
for i := range mcpTools {
if mcpTools[i].name == name {
return &mcpTools[i]
}
}
return nil
}
func apiError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func (a *app) registerAPI(mux *http.ServeMux) {
for _, rt := range apiRoutes {
mux.HandleFunc(rt.method+" "+rt.path, a.apiHandler(rt))
}
a.registerAreas(mux)
mux.HandleFunc("GET /api/v1/options", a.apiOptions)
mux.HandleFunc("GET /api/openapi.json", a.apiSpec)
}
func (a *app) apiHandler(rt apiRoute) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
tok, ok := a.bearer(w, r)
if !ok {
return
}
tool := toolNamed(rt.tool)
if !tok.allows(tool.scope) {
apiError(w, http.StatusForbidden, "this token may not "+tool.scope+" (scope)")
return
}
args, err := apiArgs(w, r, tool)
if err != nil {
apiError(w, http.StatusBadRequest, err.Error())
return
}
res, err := a.runTool(tok, tool, args)
if err != nil {
var bad argError
switch {
case errors.Is(err, errStale):
apiError(w, http.StatusConflict, err.Error())
case errors.As(err, &bad) || tool.scope == scopeChange:
apiError(w, http.StatusBadRequest, err.Error())
default:
apiError(w, http.StatusInternalServerError, err.Error())
}
return
}
if text, ok := res.(string); ok {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, text+"\n")
return
}
if m, ok := res.(map[string]any); ok && rt.tool == "get_settings" {
if e, ok := m["etag"].(string); ok {
w.Header().Set("ETag", `"`+e+`"`)
}
}
if m, ok := res.(map[string]any); ok && rt.tool == "update_settings" {
if e, ok := m["etag"].(string); ok {
w.Header().Set("ETag", `"`+e+`"`)
}
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(res)
}
}
// apiArgs builds a tool's arguments from the request: the query for GET
// (typed by the tool's schema), If-Match and the body for the settings patch.
func apiArgs(w http.ResponseWriter, r *http.Request, tool *mcpTool) (json.RawMessage, error) {
switch r.Method {
case http.MethodPatch:
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
return nil, errors.New("request too large")
}
var patch any
if err := json.Unmarshal(body, &patch); err != nil {
return nil, errors.New("the body must be a JSON merge patch")
}
etag := strings.Trim(strings.TrimPrefix(r.Header.Get("If-Match"), "W/"), `"`)
if etag == "" {
return nil, errors.New("send the settings' ETag in If-Match (GET /api/v1/settings)")
}
return json.Marshal(map[string]any{"etag": etag, "patch": patch})
case http.MethodGet:
props, _ := tool.schema["properties"].(map[string]any)
args := map[string]any{}
for name, p := range props {
v := r.URL.Query().Get(name)
if v == "" {
continue
}
if typ, _ := p.(map[string]any)["type"].(string); typ == "integer" {
n, err := strconv.Atoi(v)
if err != nil {
return nil, errors.New(name + " must be a whole number")
}
args[name] = n
} else {
args[name] = v
}
}
return json.Marshal(args)
}
return nil, nil
}
func (a *app) apiOptions(w http.ResponseWriter, r *http.Request) {
tok, ok := a.bearer(w, r)
if !ok {
return
}
_ = tok
data, err := os.ReadFile(a.optionsFile)
if a.optionsFile == "" || err != nil {
apiError(w, http.StatusNotFound, "no option documentation on this router")
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
// apiSpec serves the OpenAPI description. It describes the interface only (no
// router data), so it needs no token.
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(), r.Host))
}
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"}}}}
}
paths := map[string]any{}
for _, rt := range apiRoutes {
tool := toolNamed(rt.tool)
op := map[string]any{
"operationId": rt.tool,
"summary": rt.summary,
"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.", "content": map[string]any{
"application/json": map[string]any{"schema": map[string]any{}},
"text/plain": map[string]any{"schema": map[string]any{"type": "string"}},
}},
"401": errResp("No or an unknown token."),
"403": errResp("The token's scope does not allow this."),
},
}
var params []any
switch rt.method {
case "GET":
props, _ := tool.schema["properties"].(map[string]any)
required, _ := tool.schema["required"].([]string)
for name, p := range props {
params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"], "required": slices.Contains(required, name)})
}
if rt.tool == "get_logs" {
op["responses"].(map[string]any)["400"] = errResp("An argument is not valid.")
}
case "PATCH":
props := tool.schema["properties"].(map[string]any)
params = append(params, map[string]any{"name": "If-Match", "in": "header", "required": true,
"description": props["etag"].(map[string]any)["description"], "schema": map[string]any{"type": "string"}})
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{
"application/merge-patch+json": map[string]any{"schema": props["patch"]},
"application/json": map[string]any{"schema": props["patch"]},
}}
op["responses"].(map[string]any)["400"] = errResp("The patch is refused (see the message).")
op["responses"].(map[string]any)["409"] = errResp("The settings changed since the ETag was read.")
}
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{}
paths[rt.path] = entry
}
entry[strings.ToLower(rt.method)] = op
}
paths["/api/v1/options"] = map[string]any{"get": map[string]any{
"operationId": "get_options", "summary": "Every router option: type, default, description", "tags": []string{scopeRead},
"security": []any{map[string]any{"bearer": []string{}}},
"responses": map[string]any{"200": map[string]any{"description": "The options (JSON)", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{}}}}, "404": errResp("Not available.")},
}}
if schema != nil {
for p, ops := range areaPaths(schema) {
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": 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,
"components": map[string]any{
"securitySchemes": map[string]any{"bearer": map[string]any{"type": "http", "scheme": "bearer", "description": "A token from the API access page (rtr_…)."}},
"schemas": map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}},
},
}
}