From 98b0ebc130a6d3ae396e3da48ba51de216fa6197 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Thu, 5 Nov 2026 08:10:59 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- SPECS.md | 3 +- pkgs/router-webui/api.go | 42 ++++++---- pkgs/router-webui/api_test.go | 142 ++++++++++++++++++++++++++++++++ pkgs/router-webui/apitypes.go | 108 ++++++++++++++++++++++++ pkgs/router-webui/areas.go | 16 ++-- pkgs/router-webui/check.go | 12 +-- pkgs/router-webui/mcp.go | 93 ++++++++++----------- pkgs/router-webui/typeschema.go | 99 ++++++++++++++++++++++ 8 files changed, 436 insertions(+), 79 deletions(-) create mode 100644 pkgs/router-webui/apitypes.go create mode 100644 pkgs/router-webui/typeschema.go diff --git a/SPECS.md b/SPECS.md index 7c17367..f8af62f 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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. - **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/`, `.../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. - 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 - **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. -- **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. - 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. diff --git a/pkgs/router-webui/api.go b/pkgs/router-webui/api.go index abaa53e..ebf30f7 100644 --- a/pkgs/router-webui/api.go +++ b/pkgs/router-webui/api.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "reflect" "slices" "strconv" "strings" @@ -41,6 +42,14 @@ var apiRoutes = []apiRoute{ // 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 @@ -107,15 +116,11 @@ func (a *app) apiHandler(rt apiRoute) http.HandlerFunc { _, _ = 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+`"`) - } + 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) @@ -193,6 +198,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string] 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) @@ -204,10 +210,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string] "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"}}, - }}, + "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."), }, @@ -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.")}, }} if schema != nil { - for p, ops := range areaPaths(schema) { + 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{ @@ -274,7 +286,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string] "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"}}}, + "schemas": schemas, }, } } diff --git a/pkgs/router-webui/api_test.go b/pkgs/router-webui/api_test.go index 02217e0..df2c415 100644 --- a/pkgs/router-webui/api_test.go +++ b/pkgs/router-webui/api_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -282,3 +283,144 @@ func TestSpecExplainsETags(t *testing.T) { 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")}) +} diff --git a/pkgs/router-webui/apitypes.go b/pkgs/router-webui/apitypes.go new file mode 100644 index 0000000..66083b9 --- /dev/null +++ b/pkgs/router-webui/apitypes.go @@ -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 ', 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"` +} diff --git a/pkgs/router-webui/areas.go b/pkgs/router-webui/areas.go index 3dadea8..7c43924 100644 --- a/pkgs/router-webui/areas.go +++ b/pkgs/router-webui/areas.go @@ -128,11 +128,9 @@ func (a *app) areaHandler(method string) http.HandlerFunc { return } if method != http.MethodGet { - if m, ok := res.(map[string]any); ok { - if e, ok := m["etag"].(string); ok { - w.Header().Set("ETag", `"`+e+`"`) - a.sys.noteTokenEdit(tok.Name, e) - } + if r, ok := res.(EditResponse); ok { + w.Header().Set("ETag", `"`+r.ETag+`"`) + a.sys.noteTokenEdit(tok.Name, r.ETag) } } else if data, err := os.ReadFile(a.sys.settingsPath()); err == nil { 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 // 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{} var 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, "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, - "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, - "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" { 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."}), - "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 if len(parts) >= 3 { diff --git a/pkgs/router-webui/check.go b/pkgs/router-webui/check.go index f4436cd..a32190d 100644 --- a/pkgs/router-webui/check.go +++ b/pkgs/router-webui/check.go @@ -60,18 +60,18 @@ func (a *app) checkSettings() (any, error) { } 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 { - out := map[string]any{"status": "done", "ok": r.OK, "forTheSavedSettings": current, "checkedAt": r.Time} +func (a *app) checkReport(r CheckResult, current bool) CheckResponse { + out := CheckResponse{Status: "done", OK: &r.OK, ForTheSavedSettings: ¤t, CheckedAt: &r.Time} if r.OK { - out["message"] = "The settings evaluate: the router's assertions pass." + out.Message = "The settings evaluate: the router's assertions pass." } else { - out["error"] = r.Error + out.Error = r.Error } 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 } diff --git a/pkgs/router-webui/mcp.go b/pkgs/router-webui/mcp.go index 87c0d01..56d3fae 100644 --- a/pkgs/router-webui/mcp.go +++ b/pkgs/router-webui/mcp.go @@ -204,6 +204,7 @@ func (a *app) mcpDispatch(tok Token, req rpcRequest) (any, *rpcError) { type mcpTool struct { name, description, scope string 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) } @@ -247,10 +248,8 @@ func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, erro } a.sys.audit(entry) if err == nil && tool.name == "update_settings" { - if m, ok := res.(map[string]any); ok { - if e, ok := m["etag"].(string); ok { - a.sys.noteTokenEdit(tok.Name, e) - } + if r, ok := res.(EditResponse); ok { + a.sys.noteTokenEdit(tok.Name, r.ETag) } } return res, err @@ -270,54 +269,63 @@ func etag(data []byte) string { return hex.EncodeToString(h[:8]) } -func finite(f float64) any { +func finite(f float64) *float64 { if math.IsNaN(f) || math.IsInf(f, 0) { 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 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.", run: func(a *app, _ json.RawMessage) (any, error) { snap := a.metrics.Snapshot() st := a.sys.Status() u, s := snap.Uplink, snap.System - var pings []map[string]any + pings := []PingInfo{} 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{ - "time": snap.Time, - "uplink": map[string]any{"device": u.Device, "type": u.Type, "connected": u.Connected, "reachable": u.Reachable, - "ipv4": u.IPv4, "ipv6": u.IPv6, "gateway": u.Gateway, "since": u.Since}, - "pings": pings, - "system": map[string]any{"uptimeSeconds": int(s.Uptime.Seconds()), "load": s.Load, "kernel": s.Kernel, - "rebootForKernel": s.KernelNext, "rebootForConfiguration": s.BootPending, "cpuPercent": finite(s.CPU), - "memoryUsedBytes": s.MemUsed, "memoryTotalBytes": s.MemTotal, "temperatureC": finite(s.Temperature), - "diskUsedBytes": s.DiskUsed, "diskTotalBytes": s.DiskTotal}, - "settings": map[string]any{"unapplied": st.Unapplied, "applying": st.Applying, - "lastApplyResult": st.ApplyResult, "confirmationDueInSeconds": int(st.RollbackIn.Seconds())}, + return StatusResponse{ + Time: snap.Time, + Uplink: UplinkInfo{Device: u.Device, Type: u.Type, Connected: u.Connected, Reachable: u.Reachable, + IPv4: orEmpty(u.IPv4), IPv6: orEmpty(u.IPv6), Gateway: u.Gateway, Since: u.Since}, + Pings: pings, + System: SystemInfo{UptimeSeconds: int(s.Uptime.Seconds()), Load: s.Load, Kernel: s.Kernel, + RebootForKernel: s.KernelNext, RebootForConfiguration: s.BootPending, CPUPercent: finite(s.CPU), + MemoryUsedBytes: s.MemUsed, MemoryTotalBytes: s.MemTotal, TemperatureC: finite(s.Temperature), + DiskUsedBytes: s.DiskUsed, DiskTotalBytes: s.DiskTotal}, + Settings: SettingsInfo{Unapplied: st.Unapplied, Applying: st.Applying, + LastApplyResult: st.ApplyResult, ConfirmationDueInSeconds: int(st.RollbackIn.Seconds())}, }, 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.", run: func(a *app, _ json.RawMessage) (any, error) { data, err := os.ReadFile(a.sys.settingsPath()) if err != nil { return nil, err } - var v any + var v map[string]any if err := json.Unmarshal(data, &v); err != nil { 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).", run: func(a *app, _ json.RawMessage) (any, error) { s, err := loadSettings(a.sys.settingsPath()) if err != nil { return nil, err } - var out []map[string]any + out := []InterfaceInfo{} for _, d := range a.devices(s, s.form()) { - e := map[string]any{"name": d.Name, "kind": d.Kind, "usedAs": d.Role} - 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) + out = append(out, InterfaceInfo{Name: d.Name, Kind: d.Kind, UsedAs: d.Role, KernelName: d.Kernel, MAC: d.MAC, DeviceName: d.Custom}) } 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.", run: func(a *app, _ json.RawMessage) (any, error) { s, err := loadSettings(a.sys.settingsPath()) if err != nil { return nil, err } - var out []map[string]any + out := []LeaseInfo{} 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 }, }, { - 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.", run: func(a *app, _ json.RawMessage) (any, error) { 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.", run: func(a *app, _ json.RawMessage) (any, error) { 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, { 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.", 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{ "type": "object", "properties": map[string]any{ @@ -535,7 +533,6 @@ func (a *app) editSettings(wantEtag string, edit func(next Settings) (Settings, return nil, err } after, _ := os.ReadFile(a.sys.settingsPath()) - return map[string]any{"saved": true, "applied": false, "etag": etag(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 + return EditResponse{Saved: true, ETag: etag(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 } diff --git a/pkgs/router-webui/typeschema.go b/pkgs/router-webui/typeschema.go new file mode 100644 index 0000000..92a6d3d --- /dev/null +++ b/pkgs/router-webui/typeschema.go @@ -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"}}} +}