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"}}} +}