Every tool now returns a Go struct (apitypes.go) instead of a map, and typeschema.go derives the OpenAPI response schemas from those types by reflection (json, doc and enum tags), so the document cannot drift from what is sent. Text results are documented as text/plain, settings in answers use the typed settings schema, and edits answer with EditResponse. TestResponsesMatchTheirSchemas checks real responses against the generated schemas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
293 lines
11 KiB
Go
293 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"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"}}}
|
|
|
|
// resultContent is the 200 response's content: the result's type as JSON, or text.
|
|
func resultContent(b *schemaBuilder, tool *mcpTool) map[string]any {
|
|
if tool.output == nil {
|
|
return map[string]any{"text/plain": map[string]any{"schema": map[string]any{"type": "string"}}}
|
|
}
|
|
return map[string]any{"application/json": map[string]any{"schema": b.of(reflect.TypeOf(tool.output))}}
|
|
}
|
|
|
|
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
|
|
}
|
|
switch r := res.(type) {
|
|
case SettingsResponse:
|
|
w.Header().Set("ETag", `"`+r.ETag+`"`)
|
|
case EditResponse:
|
|
w.Header().Set("ETag", `"`+r.ETag+`"`)
|
|
}
|
|
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"}}}}
|
|
}
|
|
b := newSchemaBuilder()
|
|
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": resultContent(b, tool)},
|
|
"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, b.of(reflect.TypeOf(EditResponse{}))) {
|
|
paths[p] = ops
|
|
}
|
|
}
|
|
addCurlSamples(paths, "https://"+host)
|
|
if schema != nil { // the settings in the answers are the typed settings
|
|
if sr, ok := b.defs["SettingsResponse"].(map[string]any); ok {
|
|
sr["properties"].(map[string]any)["settings"] = schema
|
|
}
|
|
}
|
|
schemas := map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}}
|
|
for k, v := range b.defs {
|
|
schemas[k] = v
|
|
}
|
|
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": schemas,
|
|
},
|
|
}
|
|
}
|