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
281 lines
11 KiB
Go
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"}}},
|
|
},
|
|
}
|
|
}
|