Type the REST API's responses

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
This commit is contained in:
2026-11-05 08:10:59 +01:00
co-authored by Claude Sonnet 5.5
parent e3209569bd
commit 98b0ebc130
8 changed files with 436 additions and 79 deletions
+2 -1
View File
@@ -125,6 +125,7 @@ Learned along the way:
- `/api/v1/…` with an OpenAPI 3.1 description at `GET /api/openapi.json` (no token: it describes the interface only). Same tokens, scopes, origin check, limits and audit log as MCP. - `/api/v1/…` with an OpenAPI 3.1 description at `GET /api/openapi.json` (no token: it describes the interface only). Same tokens, scopes, origin check, limits and audit log as MCP.
- **One implementation**: each operation runs an MCP tool (`apiRoutes` in `api.go` maps method and path to a tool; query parameters or `If-Match` and the body become its arguments), and `TestOpenAPI` checks that every tool has an operation. Status, settings (`ETag` header), `PATCH /settings` (merge patch, `If-Match`; 409 when stale), `settings/check`, `changes` (GET the diff as text, DELETE to discard), `interfaces`, `dhcp-leases`, `update`, `apply-log`, `logs`, `options`. - **One implementation**: each operation runs an MCP tool (`apiRoutes` in `api.go` maps method and path to a tool; query parameters or `If-Match` and the body become its arguments), and `TestOpenAPI` checks that every tool has an operation. Status, settings (`ETag` header), `PATCH /settings` (merge patch, `If-Match`; 409 when stale), `settings/check`, `changes` (GET the diff as text, DELETE to discard), `interfaces`, `dhcp-leases`, `update`, `apply-log`, `logs`, `options`.
- **Settings areas** (`/api/v1/settings/router/firewall`, `.../router/lans/<name>`, `.../system`, down to a single option): `GET` reads, `PUT` replaces, `PATCH` merges (objects), `DELETE` removes, with the settings' `ETag`/`If-Match` and the same limits as the whole-document edit (`editSettings`). Values are checked against a JSON Schema made from the option documentation (`schema.go` reads the types nixosOptionsDoc describes in words, e.g. "null or integer between 1 and 4094"; a type it does not know becomes "anything", so it never refuses a valid setting): a wrong type, a value out of range or a key that is not an option is refused with its place. The OpenAPI document has these paths (three levels deep) with their schemas; lists are replaced whole (elements have no paths). Tested with the real option file (`ROUTER_OPTIONS_JSON`, `LIVE_SETTINGS_JSON` for a router's own settings) and in `apply`. - **Settings areas** (`/api/v1/settings/router/firewall`, `.../router/lans/<name>`, `.../system`, down to a single option): `GET` reads, `PUT` replaces, `PATCH` merges (objects), `DELETE` removes, with the settings' `ETag`/`If-Match` and the same limits as the whole-document edit (`editSettings`). Values are checked against a JSON Schema made from the option documentation (`schema.go` reads the types nixosOptionsDoc describes in words, e.g. "null or integer between 1 and 4094"; a type it does not know becomes "anything", so it never refuses a valid setting): a wrong type, a value out of range or a key that is not an option is refused with its place. The OpenAPI document has these paths (three levels deep) with their schemas; lists are replaced whole (elements have no paths). Tested with the real option file (`ROUTER_OPTIONS_JSON`, `LIVE_SETTINGS_JSON` for a router's own settings) and in `apply`.
- **Typed responses**: every tool returns a Go struct (`apitypes.go`); `typeschema.go` derives the OpenAPI response schemas from those types by reflection (`json` tags, `doc` and `enum` tags), so the document cannot drift from what is sent. `TestResponsesMatchTheirSchemas` checks real responses against the generated schemas. The settings in answers use the typed settings schema when the option documentation is present. Hand-written reflection, not a library: the web UI is stdlib-only (`vendorHash = null`).
- **API reference** page in the web UI (`/apireference`, linked from API access): Redoc (pinned `redoc@2.5.2` bundle, fetched by `pkgs/router-webui/package.nix`, served from the UI's own address) rendering the generated `/api/openapi.json`. Only that page gets a nonce for Redoc's styles and `worker-src blob:` in its CSP. The spec's own text explains authentication, saved-not-applied, ETags/`If-Match`/409 and errors; every operation has a curl sample. Established tooling instead of a hand-built page (swaggo rejected: spec is generated at runtime from the MCP tools and the option docs). No "try it" form. - **API reference** page in the web UI (`/apireference`, linked from API access): Redoc (pinned `redoc@2.5.2` bundle, fetched by `pkgs/router-webui/package.nix`, served from the UI's own address) rendering the generated `/api/openapi.json`. Only that page gets a nonce for Redoc's styles and `worker-src blob:` in its CSP. The spec's own text explains authentication, saved-not-applied, ETags/`If-Match`/409 and errors; every operation has a curl sample. Established tooling instead of a hand-built page (swaggo rejected: spec is generated at runtime from the MCP tools and the option docs). No "try it" form.
- Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500. - Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500.
@@ -225,7 +226,7 @@ verity. The image module and its test (`checks.image`) stay as they are for anyo
Remote access Remote access
- **MCP and REST: an `apply` scope**: build, activate and confirm. A token with it changes the running system, so it needs its own design (probably a short expiry and a confirmation the token cannot give). - **MCP and REST: an `apply` scope**: build, activate and confirm. A token with it changes the running system, so it needs its own design (probably a short expiry and a confirmation the token cannot give).
- **More read tools**: metrics history, WireGuard status (handshakes, transfer), certificate status. - **More read tools**: metrics history, WireGuard status (handshakes, transfer), certificate status.
- **REST API**: the rest of what the web UI does (apply and confirm, metrics, updates); typed schemas for the responses of the read operations (status, leases, interfaces; settings areas are typed); the web UI could use the same handlers. - **REST API**: the rest of what the web UI does (apply and confirm, metrics, updates); the web UI could use the same handlers.
- **GraphQL** on top of it, possibly: one query for the overview and for related settings. - **GraphQL** on top of it, possibly: one query for the overview and for related settings.
- Rule for all of it: changes go through `settings.json` and `router-apply` (commit-confirm), never straight to the live system; tokens have scopes; every call is logged. - Rule for all of it: changes go through `settings.json` and `router-apply` (commit-confirm), never straight to the live system; tokens have scopes; every call is logged.
+27 -15
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"reflect"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -41,6 +42,14 @@ var apiRoutes = []apiRoute{
// etagHeader documents the ETag header of the answers about the settings. // 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"}}} 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 { func routeText(rt apiRoute, tool *mcpTool) string {
if rt.description != "" { if rt.description != "" {
return rt.description return rt.description
@@ -107,15 +116,11 @@ func (a *app) apiHandler(rt apiRoute) http.HandlerFunc {
_, _ = io.WriteString(w, text+"\n") _, _ = io.WriteString(w, text+"\n")
return return
} }
if m, ok := res.(map[string]any); ok && rt.tool == "get_settings" { switch r := res.(type) {
if e, ok := m["etag"].(string); ok { case SettingsResponse:
w.Header().Set("ETag", `"`+e+`"`) w.Header().Set("ETag", `"`+r.ETag+`"`)
} case EditResponse:
} w.Header().Set("ETag", `"`+r.ETag+`"`)
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") w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
@@ -193,6 +198,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
errResp := func(d 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"}}}} 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{} paths := map[string]any{}
for _, rt := range apiRoutes { for _, rt := range apiRoutes {
tool := toolNamed(rt.tool) tool := toolNamed(rt.tool)
@@ -204,10 +210,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
"security": []any{map[string]any{"bearer": []string{}}}, "security": []any{map[string]any{"bearer": []string{}}},
"x-scope": tool.scope, "x-scope": tool.scope,
"responses": map[string]any{ "responses": map[string]any{
"200": map[string]any{"description": "The result.", "content": map[string]any{ "200": map[string]any{"description": "The result.", "content": resultContent(b, tool)},
"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."), "401": errResp("No or an unknown token."),
"403": errResp("The token's scope does not allow this."), "403": errResp("The token's scope does not allow this."),
}, },
@@ -253,11 +256,20 @@ func openAPISpec(version string, schema map[string]any, host string) map[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.")}, "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 { if schema != nil {
for p, ops := range areaPaths(schema) { for p, ops := range areaPaths(schema, b.of(reflect.TypeOf(EditResponse{}))) {
paths[p] = ops paths[p] = ops
} }
} }
addCurlSamples(paths, "https://"+host) 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{ return map[string]any{
"openapi": "3.1.0", "openapi": "3.1.0",
"info": map[string]any{ "info": map[string]any{
@@ -274,7 +286,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
"paths": paths, "paths": paths,
"components": map[string]any{ "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_…)."}}, "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"}}}, "schemas": schemas,
}, },
} }
} }
+142
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -282,3 +283,144 @@ func TestSpecExplainsETags(t *testing.T) {
t.Errorf("GET settings does not document its ETag header") t.Errorf("GET settings does not document its ETag header")
} }
} }
// strs reads a string or a list of strings of a decoded schema.
func strs(v any) []string {
switch x := v.(type) {
case string:
return []string{x}
case []any:
var out []string
for _, e := range x {
out = append(out, fmt.Sprint(e))
}
return out
}
return nil
}
// conforms checks a decoded JSON value against a schema of the OpenAPI
// document (the subset typeschema.go emits), resolving $ref in spec.
func conforms(v any, s map[string]any, spec map[string]any, path string) []string {
if ref, ok := s["$ref"].(string); ok {
name := strings.TrimPrefix(ref, "#/components/schemas/")
def, _ := spec["components"].(map[string]any)["schemas"].(map[string]any)[name].(map[string]any)
if def == nil {
return []string{path + ": unknown schema " + name}
}
return conforms(v, def, spec, path)
}
if branches, ok := s["anyOf"].([]any); ok {
for _, b := range branches {
if len(conforms(v, b.(map[string]any), spec, path)) == 0 {
return nil
}
}
return []string{path + ": matches none of the alternatives"}
}
types := strs(s["type"])
if len(types) == 0 {
return nil // any
}
kind := "null"
switch v.(type) {
case bool:
kind = "boolean"
case string:
kind = "string"
case float64:
kind = "number"
case []any:
kind = "array"
case map[string]any:
kind = "object"
}
if f, ok := v.(float64); ok && f == float64(int64(f)) && slices.Contains(types, "integer") {
kind = "integer"
}
if !slices.Contains(types, kind) {
return []string{path + ": is " + kind + ", the schema says " + strings.Join(types, " or ")}
}
var errs []string
switch x := v.(type) {
case string:
if s["format"] == "date-time" {
if _, err := time.Parse(time.RFC3339Nano, x); err != nil {
errs = append(errs, path+": not a date-time: "+x)
}
}
if enum := strs(s["enum"]); enum != nil && !slices.Contains(enum, x) {
errs = append(errs, path+": "+x+" is not in the enum")
}
case []any:
items, _ := s["items"].(map[string]any)
for i, e := range x {
errs = append(errs, conforms(e, items, spec, fmt.Sprintf("%s[%d]", path, i))...)
}
case map[string]any:
props, _ := s["properties"].(map[string]any)
{
for _, k := range strs(s["required"]) {
if _, ok := x[k]; !ok {
errs = append(errs, path+": "+k+" is missing")
}
}
}
for k, c := range x {
if p, ok := props[k].(map[string]any); ok {
errs = append(errs, conforms(c, p, spec, path+"/"+k)...)
} else if add, ok := s["additionalProperties"].(map[string]any); ok {
errs = append(errs, conforms(c, add, spec, path+"/"+k)...)
} else if props != nil {
errs = append(errs, path+": "+k+" is not in the schema")
}
}
}
return errs
}
// What the API answers is what the OpenAPI document says it answers.
func TestResponsesMatchTheirSchemas(t *testing.T) {
a, sys := newTestApp(t)
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
os.WriteFile(sys.confirmedPath(), data, 0o600)
}
h := a.routes()
_, tok, _ := a.tokens.Create("changer", "change")
// Through JSON, as a client sees the document.
var spec map[string]any
data, _ := json.Marshal(openAPISpec("1.2.3", nil, "router.example"))
json.Unmarshal(data, &spec)
check := func(method, path, body string, headers map[string]string) {
t.Helper()
rec := rest(h, tok, method, path, body, headers)
if rec.Code != 200 {
t.Errorf("%s %s: %d %s", method, path, rec.Code, rec.Body)
return
}
op := spec["paths"].(map[string]any)[path].(map[string]any)[strings.ToLower(method)].(map[string]any)
content := op["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)
js, ok := content["application/json"].(map[string]any)
if !ok {
if _, text := content["text/plain"]; !text || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
t.Errorf("%s %s: documented %v, sent %s", method, path, content, rec.Header().Get("Content-Type"))
}
return
}
var v any
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Errorf("%s %s: %v", method, path, err)
return
}
if errs := conforms(v, js["schema"].(map[string]any), spec, ""); len(errs) > 0 {
t.Errorf("%s %s does not match its schema: %v\n%s", method, path, errs, rec.Body)
}
}
for _, p := range []string{"/api/v1/status", "/api/v1/settings", "/api/v1/interfaces", "/api/v1/dhcp-leases", "/api/v1/update", "/api/v1/apply-log"} {
check("GET", p, "", nil)
}
check("GET", "/api/v1/changes", "", nil)
check("DELETE", "/api/v1/changes", "", nil)
rec := rest(h, tok, "GET", "/api/v1/settings", "", nil)
check("PATCH", "/api/v1/settings", `{"system":{"hostName":"gw"}}`, map[string]string{"If-Match": rec.Header().Get("ETag")})
}
+108
View File
@@ -0,0 +1,108 @@
package main
import "time"
// What the REST API (and the MCP tools, which are the same code) answer. The
// OpenAPI document's response schemas are derived from these types.
type StatusResponse struct {
Time time.Time `json:"time" doc:"When the figures were taken."`
Uplink UplinkInfo `json:"uplink"`
Pings []PingInfo `json:"pings"`
System SystemInfo `json:"system"`
Settings SettingsInfo `json:"settings"`
}
type UplinkInfo struct {
Device string `json:"device" doc:"The uplink device: pppoe-wan, or the WAN interface."`
Type string `json:"type" doc:"How the uplink is configured: dhcp or pppoe."`
Connected bool `json:"connected" doc:"The uplink has an IPv4 address and the default route."`
Reachable bool `json:"reachable" doc:"At least one public ping target answered."`
IPv4 []string `json:"ipv4" doc:"The uplink's IPv4 addresses."`
IPv6 []string `json:"ipv6" doc:"The uplink's IPv6 addresses."`
Gateway string `json:"gateway" doc:"The default gateway ('' if none)."`
Since time.Time `json:"since" doc:"When the uplink last changed state."`
}
type PingInfo struct {
Target string `json:"target" doc:"As configured: 'gateway' or an address."`
Address string `json:"address" doc:"The address that was pinged."`
OK bool `json:"ok" doc:"It answered."`
RttMs int64 `json:"rttMs" doc:"Round-trip time in milliseconds."`
}
type SystemInfo struct {
UptimeSeconds int `json:"uptimeSeconds"`
Load string `json:"load" doc:"Load averages (1, 5, 15 minutes)."`
Kernel string `json:"kernel" doc:"The running kernel."`
RebootForKernel string `json:"rebootForKernel" doc:"The kernel of the current system when it differs from the running one: a reboot is due ('' if none)."`
RebootForConfiguration bool `json:"rebootForConfiguration" doc:"A configuration is staged for the next boot (interface names)."`
CPUPercent *float64 `json:"cpuPercent" doc:"null until the second sample."`
MemoryUsedBytes uint64 `json:"memoryUsedBytes"`
MemoryTotalBytes uint64 `json:"memoryTotalBytes"`
TemperatureC *float64 `json:"temperatureC" doc:"The hottest sensor; null if there is none."`
DiskUsedBytes uint64 `json:"diskUsedBytes"`
DiskTotalBytes uint64 `json:"diskTotalBytes"`
}
type SettingsInfo struct {
Unapplied bool `json:"unapplied" doc:"The saved settings differ from the confirmed (running) ones."`
Applying bool `json:"applying" doc:"An apply is running."`
LastApplyResult string `json:"lastApplyResult" doc:"Result of the last apply: success, exit-code, ... ('' if none)."`
ConfirmationDueInSeconds int `json:"confirmationDueInSeconds" doc:"While a change awaits confirmation: the time left before the router rolls back (0 if none)."`
}
type SettingsResponse struct {
ETag string `json:"etag" doc:"The settings' version; the same as the ETag header (without quotes)."`
Unapplied bool `json:"unapplied" doc:"The saved settings differ from the confirmed (running) ones."`
Settings map[string]any `json:"settings" doc:"The saved settings (settings.json)."`
}
type EditResponse struct {
Saved bool `json:"saved" doc:"The edit was written to the saved settings."`
Applied bool `json:"applied" doc:"Always false: a person applies changes in the web UI."`
ETag string `json:"etag" doc:"The settings' new version, for the next edit."`
Changes string `json:"changes" doc:"What this edit changed, as diff lines (- removed, + added)."`
Next string `json:"next" doc:"What to do next."`
}
type InterfaceInfo struct {
Name string `json:"name" doc:"The name from the settings."`
Kind string `json:"kind" enum:"physical,VLAN"`
UsedAs string `json:"usedAs" doc:"WAN, 'LAN <name>', or '' if unused."`
KernelName string `json:"kernelName,omitempty" doc:"Physical: the name the kernel gives the card now."`
MAC string `json:"mac,omitempty" doc:"Physical: the card's MAC address, when it is on this machine."`
DeviceName string `json:"deviceName,omitempty" doc:"VLAN: the name chosen for its device."`
}
type LeaseInfo struct {
Address string `json:"address"`
MAC string `json:"mac"`
Hostname string `json:"hostname"`
LAN string `json:"lan"`
Expires time.Time `json:"expires"`
}
type UpdateStatusResponse struct {
Installed Release `json:"installed"`
Running bool `json:"running" doc:"An update is being installed."`
LastResult string `json:"lastResult" doc:"Result of the last update run ('' if none)."`
Log string `json:"log" doc:"The last update run's log."`
}
type ApplyLogResponse struct {
Applying bool `json:"applying"`
Result string `json:"result" doc:"success, exit-code, ... ('' if none or still running)."`
Log string `json:"log" doc:"The build and activation log."`
}
type CheckResponse struct {
Status string `json:"status" enum:"done,running" doc:"'running': still evaluating, ask again in a few seconds."`
OK *bool `json:"ok,omitempty" doc:"The settings evaluate and the router's assertions pass."`
ForTheSavedSettings *bool `json:"forTheSavedSettings,omitempty" doc:"The result is for the current saved settings (not older ones)."`
CheckedAt *time.Time `json:"checkedAt,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty" doc:"Why the settings do not evaluate."`
Note string `json:"note,omitempty"`
Next string `json:"next,omitempty"`
}
+7 -9
View File
@@ -128,11 +128,9 @@ func (a *app) areaHandler(method string) http.HandlerFunc {
return return
} }
if method != http.MethodGet { if method != http.MethodGet {
if m, ok := res.(map[string]any); ok { if r, ok := res.(EditResponse); ok {
if e, ok := m["etag"].(string); ok { w.Header().Set("ETag", `"`+r.ETag+`"`)
w.Header().Set("ETag", `"`+e+`"`) a.sys.noteTokenEdit(tok.Name, r.ETag)
a.sys.noteTokenEdit(tok.Name, e)
}
} }
} else if data, err := os.ReadFile(a.sys.settingsPath()); err == nil { } else if data, err := os.ReadFile(a.sys.settingsPath()); err == nil {
w.Header().Set("ETag", `"`+etag(data)+`"`) w.Header().Set("ETag", `"`+etag(data)+`"`)
@@ -216,7 +214,7 @@ func (a *app) area(method string, segs []string, area map[string]any, body []byt
// areaOps lists the paths of the areas for the OpenAPI document: the options // areaOps lists the paths of the areas for the OpenAPI document: the options
// to a depth of three (router/lans/{name}); deeper paths work the same. // to a depth of three (router/lans/{name}); deeper paths work the same.
func areaPaths(schema map[string]any) map[string]any { func areaPaths(schema, edited map[string]any) map[string]any {
paths := map[string]any{} paths := map[string]any{}
var emit func(parts []string, s map[string]any) var emit func(parts []string, s map[string]any)
emit = func(parts []string, s map[string]any) { emit = func(parts []string, s map[string]any) {
@@ -251,14 +249,14 @@ func areaPaths(schema map[string]any) map[string]any {
"get": map[string]any{"operationId": "get_" + id, "summary": "Read " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec, "get": map[string]any{"operationId": "get_" + id, "summary": "Read " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
"parameters": params, "responses": ok200("The value.", s), "x-scope": scopeRead}, "parameters": params, "responses": ok200("The value.", s), "x-scope": scopeRead},
"put": map[string]any{"operationId": "put_" + id, "summary": "Replace " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec, "put": map[string]any{"operationId": "put_" + id, "summary": "Replace " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
"parameters": withParams(ifMatch), "requestBody": jsonBody(node), "responses": ok200("Saved, not applied: the result has the new ETag and the difference.", map[string]any{"type": "object"}), "x-scope": scopeChange}, "parameters": withParams(ifMatch), "requestBody": jsonBody(node), "responses": ok200("Saved, not applied: the result has the new ETag and the difference.", edited), "x-scope": scopeChange},
"delete": map[string]any{"operationId": "delete_" + id, "summary": "Remove " + strings.Join(parts, "/") + " (its defaults apply again)", "tags": []string{"settings"}, "security": sec, "delete": map[string]any{"operationId": "delete_" + id, "summary": "Remove " + strings.Join(parts, "/") + " (its defaults apply again)", "tags": []string{"settings"}, "security": sec,
"parameters": withParams(ifMatch), "responses": ok200("Saved, not applied.", map[string]any{"type": "object"}), "x-scope": scopeChange}, "parameters": withParams(ifMatch), "responses": ok200("Saved, not applied.", edited), "x-scope": scopeChange},
} }
if node["type"] == "object" { if node["type"] == "object" {
ops["patch"] = map[string]any{"operationId": "patch_" + id, "summary": "Merge into " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec, ops["patch"] = map[string]any{"operationId": "patch_" + id, "summary": "Merge into " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
"parameters": withParams(ifMatch), "requestBody": jsonBody(map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386): objects merge, a value replaces, null removes a key, arrays are replaced whole."}), "parameters": withParams(ifMatch), "requestBody": jsonBody(map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386): objects merge, a value replaces, null removes a key, arrays are replaced whole."}),
"responses": ok200("Saved, not applied.", map[string]any{"type": "object"}), "x-scope": scopeChange} "responses": ok200("Saved, not applied.", edited), "x-scope": scopeChange}
} }
paths[path] = ops paths[path] = ops
if len(parts) >= 3 { if len(parts) >= 3 {
+6 -6
View File
@@ -60,18 +60,18 @@ func (a *app) checkSettings() (any, error) {
} }
break break
} }
return map[string]any{"status": "running", "next": "Still evaluating; call check_settings again in a few seconds."}, nil return CheckResponse{Status: "running", Next: "Still evaluating; call check_settings again in a few seconds."}, nil
} }
func (a *app) checkReport(r CheckResult, current bool) map[string]any { func (a *app) checkReport(r CheckResult, current bool) CheckResponse {
out := map[string]any{"status": "done", "ok": r.OK, "forTheSavedSettings": current, "checkedAt": r.Time} out := CheckResponse{Status: "done", OK: &r.OK, ForTheSavedSettings: &current, CheckedAt: &r.Time}
if r.OK { if r.OK {
out["message"] = "The settings evaluate: the router's assertions pass." out.Message = "The settings evaluate: the router's assertions pass."
} else { } else {
out["error"] = r.Error out.Error = r.Error
} }
if !current { if !current {
out["note"] = "The settings changed during or after this check; call check_settings again." out.Note = "The settings changed during or after this check; call check_settings again."
} }
return out return out
} }
+45 -48
View File
@@ -204,6 +204,7 @@ func (a *app) mcpDispatch(tok Token, req rpcRequest) (any, *rpcError) {
type mcpTool struct { type mcpTool struct {
name, description, scope string name, description, scope string
schema map[string]any schema map[string]any
output any // a value of the result's type (nil: the result is text), for the OpenAPI document
run func(a *app, args json.RawMessage) (any, error) run func(a *app, args json.RawMessage) (any, error)
} }
@@ -247,10 +248,8 @@ func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, erro
} }
a.sys.audit(entry) a.sys.audit(entry)
if err == nil && tool.name == "update_settings" { if err == nil && tool.name == "update_settings" {
if m, ok := res.(map[string]any); ok { if r, ok := res.(EditResponse); ok {
if e, ok := m["etag"].(string); ok { a.sys.noteTokenEdit(tok.Name, r.ETag)
a.sys.noteTokenEdit(tok.Name, e)
}
} }
} }
return res, err return res, err
@@ -270,54 +269,63 @@ func etag(data []byte) string {
return hex.EncodeToString(h[:8]) return hex.EncodeToString(h[:8])
} }
func finite(f float64) any { func finite(f float64) *float64 {
if math.IsNaN(f) || math.IsInf(f, 0) { if math.IsNaN(f) || math.IsInf(f, 0) {
return nil return nil
} }
return math.Round(f*10) / 10 r := math.Round(f*10) / 10
return &r
}
// orEmpty makes a nil list an empty one, so that it is [] in the JSON.
func orEmpty[T any](l []T) []T {
if l == nil {
return []T{}
}
return l
} }
var emptySchema = map[string]any{"type": "object", "properties": map[string]any{}} var emptySchema = map[string]any{"type": "object", "properties": map[string]any{}}
var mcpTools = []mcpTool{ var mcpTools = []mcpTool{
{ {
name: "get_status", scope: scopeRead, schema: emptySchema, name: "get_status", scope: scopeRead, schema: emptySchema, output: StatusResponse{},
description: "The router's state now: uplink (connected, addresses, gateway, pings), system (uptime, load, CPU, memory, disk, temperature, kernel), whether settings are unapplied, and whether an apply is running or waits for confirmation.", description: "The router's state now: uplink (connected, addresses, gateway, pings), system (uptime, load, CPU, memory, disk, temperature, kernel), whether settings are unapplied, and whether an apply is running or waits for confirmation.",
run: func(a *app, _ json.RawMessage) (any, error) { run: func(a *app, _ json.RawMessage) (any, error) {
snap := a.metrics.Snapshot() snap := a.metrics.Snapshot()
st := a.sys.Status() st := a.sys.Status()
u, s := snap.Uplink, snap.System u, s := snap.Uplink, snap.System
var pings []map[string]any pings := []PingInfo{}
for _, p := range snap.Pings { for _, p := range snap.Pings {
pings = append(pings, map[string]any{"target": p.Target, "address": p.Address, "ok": p.OK, "rttMs": p.RTT.Milliseconds()}) pings = append(pings, PingInfo{Target: p.Target, Address: p.Address, OK: p.OK, RttMs: p.RTT.Milliseconds()})
} }
return map[string]any{ return StatusResponse{
"time": snap.Time, Time: snap.Time,
"uplink": map[string]any{"device": u.Device, "type": u.Type, "connected": u.Connected, "reachable": u.Reachable, Uplink: UplinkInfo{Device: u.Device, Type: u.Type, Connected: u.Connected, Reachable: u.Reachable,
"ipv4": u.IPv4, "ipv6": u.IPv6, "gateway": u.Gateway, "since": u.Since}, IPv4: orEmpty(u.IPv4), IPv6: orEmpty(u.IPv6), Gateway: u.Gateway, Since: u.Since},
"pings": pings, Pings: pings,
"system": map[string]any{"uptimeSeconds": int(s.Uptime.Seconds()), "load": s.Load, "kernel": s.Kernel, System: SystemInfo{UptimeSeconds: int(s.Uptime.Seconds()), Load: s.Load, Kernel: s.Kernel,
"rebootForKernel": s.KernelNext, "rebootForConfiguration": s.BootPending, "cpuPercent": finite(s.CPU), RebootForKernel: s.KernelNext, RebootForConfiguration: s.BootPending, CPUPercent: finite(s.CPU),
"memoryUsedBytes": s.MemUsed, "memoryTotalBytes": s.MemTotal, "temperatureC": finite(s.Temperature), MemoryUsedBytes: s.MemUsed, MemoryTotalBytes: s.MemTotal, TemperatureC: finite(s.Temperature),
"diskUsedBytes": s.DiskUsed, "diskTotalBytes": s.DiskTotal}, DiskUsedBytes: s.DiskUsed, DiskTotalBytes: s.DiskTotal},
"settings": map[string]any{"unapplied": st.Unapplied, "applying": st.Applying, Settings: SettingsInfo{Unapplied: st.Unapplied, Applying: st.Applying,
"lastApplyResult": st.ApplyResult, "confirmationDueInSeconds": int(st.RollbackIn.Seconds())}, LastApplyResult: st.ApplyResult, ConfirmationDueInSeconds: int(st.RollbackIn.Seconds())},
}, nil }, nil
}, },
}, },
{ {
name: "get_settings", scope: scopeRead, schema: emptySchema, name: "get_settings", scope: scopeRead, schema: emptySchema, output: SettingsResponse{},
description: "The saved settings (settings.json: `system` and `router`) with the etag to pass to update_settings, and whether they differ from the confirmed (running) system. Secrets are not in it, only paths of files that hold them.", description: "The saved settings (settings.json: `system` and `router`) with the etag to pass to update_settings, and whether they differ from the confirmed (running) system. Secrets are not in it, only paths of files that hold them.",
run: func(a *app, _ json.RawMessage) (any, error) { run: func(a *app, _ json.RawMessage) (any, error) {
data, err := os.ReadFile(a.sys.settingsPath()) data, err := os.ReadFile(a.sys.settingsPath())
if err != nil { if err != nil {
return nil, err return nil, err
} }
var v any var v map[string]any
if err := json.Unmarshal(data, &v); err != nil { if err := json.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return map[string]any{"etag": etag(data), "unapplied": a.sys.Status().Unapplied, "settings": v}, nil return SettingsResponse{ETag: etag(data), Unapplied: a.sys.Status().Unapplied, Settings: v}, nil
}, },
}, },
{ {
@@ -331,70 +339,60 @@ var mcpTools = []mcpTool{
}, },
}, },
{ {
name: "list_interfaces", scope: scopeRead, schema: emptySchema, name: "list_interfaces", scope: scopeRead, schema: emptySchema, output: []InterfaceInfo{},
description: "Network interfaces: physical cards (name now, name from the settings, MAC address) and VLAN devices, and how the settings use each (WAN, a LAN).", description: "Network interfaces: physical cards (name now, name from the settings, MAC address) and VLAN devices, and how the settings use each (WAN, a LAN).",
run: func(a *app, _ json.RawMessage) (any, error) { run: func(a *app, _ json.RawMessage) (any, error) {
s, err := loadSettings(a.sys.settingsPath()) s, err := loadSettings(a.sys.settingsPath())
if err != nil { if err != nil {
return nil, err return nil, err
} }
var out []map[string]any out := []InterfaceInfo{}
for _, d := range a.devices(s, s.form()) { for _, d := range a.devices(s, s.form()) {
e := map[string]any{"name": d.Name, "kind": d.Kind, "usedAs": d.Role} out = append(out, InterfaceInfo{Name: d.Name, Kind: d.Kind, UsedAs: d.Role, KernelName: d.Kernel, MAC: d.MAC, DeviceName: d.Custom})
if d.Kernel != "" {
e["kernelName"] = d.Kernel
}
if d.MAC != "" {
e["mac"] = d.MAC
}
if d.Custom != "" {
e["deviceName"] = d.Custom
}
out = append(out, e)
} }
return out, nil return out, nil
}, },
}, },
{ {
name: "list_dhcp_leases", scope: scopeRead, schema: emptySchema, name: "list_dhcp_leases", scope: scopeRead, schema: emptySchema, output: []LeaseInfo{},
description: "The DHCP clients currently holding a lease: address, MAC, hostname, LAN, when the lease ends.", description: "The DHCP clients currently holding a lease: address, MAC, hostname, LAN, when the lease ends.",
run: func(a *app, _ json.RawMessage) (any, error) { run: func(a *app, _ json.RawMessage) (any, error) {
s, err := loadSettings(a.sys.settingsPath()) s, err := loadSettings(a.sys.settingsPath())
if err != nil { if err != nil {
return nil, err return nil, err
} }
var out []map[string]any out := []LeaseInfo{}
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) { for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
out = append(out, map[string]any{"address": l.Address, "mac": l.MAC, "hostname": l.Hostname, "lan": l.LAN, "expires": l.Expires}) out = append(out, LeaseInfo{Address: l.Address, MAC: l.MAC, Hostname: l.Hostname, LAN: l.LAN, Expires: l.Expires})
} }
return out, nil return out, nil
}, },
}, },
{ {
name: "get_update_status", scope: scopeRead, schema: emptySchema, name: "get_update_status", scope: scopeRead, schema: emptySchema, output: UpdateStatusResponse{},
description: "The installed release (version, schema) and the state of the last update run.", description: "The installed release (version, schema) and the state of the last update run.",
run: func(a *app, _ json.RawMessage) (any, error) { run: func(a *app, _ json.RawMessage) (any, error) {
st := a.sys.UpdateStatus() st := a.sys.UpdateStatus()
return map[string]any{"installed": a.sys.installedRelease(), "running": st.Running, "lastResult": st.Result, "log": st.Log}, nil return UpdateStatusResponse{Installed: a.sys.installedRelease(), Running: st.Running, LastResult: st.Result, Log: st.Log}, nil
}, },
}, },
{ {
name: "get_apply_log", scope: scopeRead, schema: emptySchema, name: "get_apply_log", scope: scopeRead, schema: emptySchema, output: ApplyLogResponse{},
description: "The log of the last apply (build and activation) and its result.", description: "The log of the last apply (build and activation) and its result.",
run: func(a *app, _ json.RawMessage) (any, error) { run: func(a *app, _ json.RawMessage) (any, error) {
st := a.sys.Status() st := a.sys.Status()
return map[string]any{"applying": st.Applying, "result": st.ApplyResult, "log": st.Log}, nil return ApplyLogResponse{Applying: st.Applying, Result: st.ApplyResult, Log: st.Log}, nil
}, },
}, },
logsTool, logsTool,
{ {
name: "check_settings", scope: scopeRead, name: "check_settings", scope: scopeRead,
schema: emptySchema, schema: emptySchema, output: CheckResponse{},
description: "Evaluates the saved settings the way an apply would (the router's assertions and the option types) without building or activating anything. Takes about a minute; if it is still running, call again to get the result. Reports whether the result is for the current saved settings.", description: "Evaluates the saved settings the way an apply would (the router's assertions and the option types) without building or activating anything. Takes about a minute; if it is still running, call again to get the result. Reports whether the result is for the current saved settings.",
run: func(a *app, _ json.RawMessage) (any, error) { return a.checkSettings() }, run: func(a *app, _ json.RawMessage) (any, error) { return a.checkSettings() },
}, },
{ {
name: "update_settings", scope: scopeChange, name: "update_settings", scope: scopeChange, output: EditResponse{},
schema: map[string]any{ schema: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
@@ -535,7 +533,6 @@ func (a *app) editSettings(wantEtag string, edit func(next Settings) (Settings,
return nil, err return nil, err
} }
after, _ := os.ReadFile(a.sys.settingsPath()) after, _ := os.ReadFile(a.sys.settingsPath())
return map[string]any{"saved": true, "applied": false, "etag": etag(after), return EditResponse{Saved: true, ETag: etag(after), Changes: diffLines(pretty(data), pretty(after)),
"changes": diffLines(pretty(data), pretty(after)), Next: "Run check_settings (GET /api/v1/settings/check); a person applies the changes in the web UI."}, nil
"next": "Run check_settings (GET /api/v1/settings/check); a person applies the changes in the web UI."}, nil
} }
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"reflect"
"strings"
"time"
)
// The REST API's responses are Go structs (apitypes.go); their JSON Schemas
// in the OpenAPI document are derived from those types, so what is documented
// is what is sent. Struct tags: `json` as usual (omitempty = optional), `doc`
// is the description, `enum` a comma-separated list of the allowed strings.
// Named structs become components/schemas.
type schemaBuilder struct {
defs map[string]any
}
func newSchemaBuilder() *schemaBuilder { return &schemaBuilder{defs: map[string]any{}} }
var timeType = reflect.TypeOf(time.Time{})
// of returns the schema of t (a $ref for a named struct).
func (b *schemaBuilder) of(t reflect.Type) map[string]any {
switch t.Kind() {
case reflect.Pointer:
return nullable(b.of(t.Elem()))
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.String:
return map[string]any{"type": "string"}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return map[string]any{"type": "integer"}
case reflect.Float32, reflect.Float64:
return map[string]any{"type": "number"}
case reflect.Slice, reflect.Array:
return map[string]any{"type": "array", "items": b.of(t.Elem())}
case reflect.Map:
return map[string]any{"type": "object", "additionalProperties": b.of(t.Elem())}
case reflect.Struct:
if t == timeType {
return map[string]any{"type": "string", "format": "date-time"}
}
if t.Name() == "" {
return b.object(t)
}
if _, ok := b.defs[t.Name()]; !ok {
b.defs[t.Name()] = nil // against recursion
b.defs[t.Name()] = b.object(t)
}
return map[string]any{"$ref": "#/components/schemas/" + t.Name()}
}
return map[string]any{} // interfaces: any value
}
func (b *schemaBuilder) object(t reflect.Type) map[string]any {
props := map[string]any{}
var required []string
for i := range t.NumField() {
f := t.Field(i)
name, opts, _ := strings.Cut(f.Tag.Get("json"), ",")
if !f.IsExported() || name == "-" {
continue
}
if name == "" {
name = f.Name
}
s := b.of(f.Type)
if d := f.Tag.Get("doc"); d != "" {
s["description"] = d
}
if e := f.Tag.Get("enum"); e != "" {
s["enum"] = strings.Split(e, ",")
}
props[name] = s
if !strings.Contains(","+opts+",", ",omitempty,") {
required = append(required, name)
}
}
out := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
out["required"] = required
}
return out
}
// nullable lets a schema also be null.
func nullable(s map[string]any) map[string]any {
if typ, ok := s["type"].(string); ok {
out := map[string]any{}
for k, v := range s {
out[k] = v
}
out["type"] = []string{typ, "null"}
return out
}
return map[string]any{"anyOf": []any{s, map[string]any{"type": "null"}}}
}